From c957d96cf493db6b3f9bee4f7b1edeb596d8dba4 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Sat, 25 Apr 2026 09:22:21 +0000 Subject: [PATCH 01/23] fix(frontend): add missing accessory bar styles for 430-768px tablet viewport The keyboard accessory bar buttons (settings, commands, Terminal/Transcript toggle, project picker, hamburger) had dark styling only in the <430px and 768-1023px breakpoints. The 430-768px tablet range had no styles, causing buttons to render with default white browser styling. Moves shared accessory bar component styles (buttons, command drawer, view mode toggle) to the global scope in mobile.css so they apply at all sizes where the file loads (<1024px). Breakpoint-specific blocks still override sizes as needed. Co-Authored-By: Claude Opus 4.6 --- src/web/public/mobile.css | 221 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 1b6f1ea0..dcf87c3f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -41,6 +41,227 @@ display: flex; } +/* ============================================================================ + Accessory Bar Buttons — base styles for ALL sizes where mobile.css loads. + Breakpoint-specific blocks may override sizes (min-height, height, etc.). + ============================================================================ */ + +.accessory-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 8px 14px; + min-height: 36px; + background: #2a2a2a; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #e5e5e5; + font-size: 0.65rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.accessory-btn:active { + background: #3a3a3a; +} + +.accessory-btn svg { + width: 14px; + height: 14px; +} + +.accessory-btn.confirming { + background: #6b4f00; + border-color: #b8860b; + color: #ffd54f; +} + +.accessory-btn-arrow { + padding: 6px 10px; + background: #1e3a5f; + border-color: rgba(59, 130, 246, 0.3); + color: #93c5fd; +} + +.accessory-btn-arrow:active { + background: #2563eb; +} + +.accessory-btn-dismiss { + padding: 8px 14px; + background: #2a2a2a; + border: 1.5px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + color: #e5e5e5; +} + +.accessory-btn-dismiss svg { + width: 22px; + height: 22px; + stroke-width: 3; +} + +.accessory-btn-dismiss:active { + background: #3a3a3a; +} + +.accessory-btn-settings { + color: #94a3b8; + flex-shrink: 0; +} + +.accessory-btn-project { + display: flex; + align-items: center; + gap: 3px; + padding: 0 8px; + min-width: 0; + max-width: 110px; + overflow: hidden; + flex-shrink: 0; +} + +.accessory-project-icon { font-size: 12px; flex-shrink: 0; } +.accessory-project-name { + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #cbd5e1; +} +.accessory-project-caret { font-size: 9px; color: #64748b; flex-shrink: 0; } + +.accessory-btn-commands { + background: #1e2a3a; + border-color: rgba(100, 160, 255, 0.3); + color: #93c5fd; + font-size: 0.75rem; + letter-spacing: 0.03em; +} +.accessory-btn-commands:active { background: #2a3a5a; } + +.accessory-btn-new { + background: #1a2a1a; + border-color: rgba(34, 197, 94, 0.3); + color: #4ade80; + font-size: 1.1rem; + font-weight: 400; + padding: 4px 10px; + line-height: 1; +} +.accessory-btn-new:active { background: #223322; } + +/* View mode segmented toggle */ +.view-mode-toggle { + display: inline-flex; + align-items: stretch; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + height: 36px; +} + +.view-mode-seg { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + background: #2a2a2a; + border: none; + border-right: 1px solid rgba(255, 255, 255, 0.1); + color: #94a3b8; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.view-mode-seg:last-child { border-right: none; } +.view-mode-seg:active { background: #3a3a3a; } +.view-mode-seg.active { + background: rgba(6, 182, 212, 0.18); + color: #22d3ee; +} +.vmt-label { display: none; } +.vmt-icon { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.75rem; + line-height: 1; +} + +/* Commands drawer */ +.accessory-cmd-drawer { + position: absolute; + bottom: 100%; + left: calc(-8px - var(--safe-area-left, 0px)); + right: calc(-8px - var(--safe-area-right, 0px)); + background: #1e1e1e; + border-top: 1px solid rgba(255, 255, 255, 0.15); + display: flex; + flex-direction: column; + pointer-events: none; + opacity: 0; + transform: translateY(8px); + transition: transform 0.18s ease-out, opacity 0.15s ease-out; + max-height: 40vh; + overflow-y: auto; +} + +.accessory-cmd-drawer.open { + pointer-events: auto; + opacity: 1; + transform: translateY(0); +} + +.accessory-cmd-search { + display: block; + width: 100%; + padding: 11px 16px; + background: #111; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + color: #e5e5e5; + font-size: 16px; + outline: none; + box-sizing: border-box; +} +.accessory-cmd-search::placeholder { color: #555; } +.accessory-cmd-search:focus { background: #181818; } + +.accessory-cmd-items { + overflow-y: auto; + max-height: calc(40vh - 44px); +} + +.accessory-drawer-item { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 10px 20px; + background: none; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + text-align: left; + cursor: pointer; +} + +.drawer-cmd-name { + color: #e5e5e5; + font-size: 0.9rem; + font-family: monospace; +} + +.drawer-cmd-desc { + color: #6b7280; + font-size: 0.75rem; + margin-top: 1px; +} + +.accessory-drawer-item:last-child { border-bottom: none; } +.accessory-drawer-item:active { background: #2a2a2a; } + /* Compose / Input Panel */ .mobile-input-panel { position: fixed; From d12199d3ad75322257a986f88e7c23890f7b276e Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Sat, 25 Apr 2026 09:22:21 +0000 Subject: [PATCH 02/23] fix(frontend): add missing accessory bar styles for 430-768px tablet viewport The keyboard accessory bar buttons (settings, commands, Terminal/Transcript toggle, project picker, hamburger) had dark styling only in the <430px and 768-1023px breakpoints. The 430-768px tablet range had no styles, causing buttons to render with default white browser styling. Moves shared accessory bar component styles (buttons, command drawer, view mode toggle) to the global scope in mobile.css so they apply at all sizes where the file loads (<1024px). Breakpoint-specific blocks still override sizes as needed. Co-Authored-By: Claude Opus 4.6 --- src/web/public/mobile.css | 221 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 1b6f1ea0..dcf87c3f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -41,6 +41,227 @@ display: flex; } +/* ============================================================================ + Accessory Bar Buttons — base styles for ALL sizes where mobile.css loads. + Breakpoint-specific blocks may override sizes (min-height, height, etc.). + ============================================================================ */ + +.accessory-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 8px 14px; + min-height: 36px; + background: #2a2a2a; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #e5e5e5; + font-size: 0.65rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.accessory-btn:active { + background: #3a3a3a; +} + +.accessory-btn svg { + width: 14px; + height: 14px; +} + +.accessory-btn.confirming { + background: #6b4f00; + border-color: #b8860b; + color: #ffd54f; +} + +.accessory-btn-arrow { + padding: 6px 10px; + background: #1e3a5f; + border-color: rgba(59, 130, 246, 0.3); + color: #93c5fd; +} + +.accessory-btn-arrow:active { + background: #2563eb; +} + +.accessory-btn-dismiss { + padding: 8px 14px; + background: #2a2a2a; + border: 1.5px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + color: #e5e5e5; +} + +.accessory-btn-dismiss svg { + width: 22px; + height: 22px; + stroke-width: 3; +} + +.accessory-btn-dismiss:active { + background: #3a3a3a; +} + +.accessory-btn-settings { + color: #94a3b8; + flex-shrink: 0; +} + +.accessory-btn-project { + display: flex; + align-items: center; + gap: 3px; + padding: 0 8px; + min-width: 0; + max-width: 110px; + overflow: hidden; + flex-shrink: 0; +} + +.accessory-project-icon { font-size: 12px; flex-shrink: 0; } +.accessory-project-name { + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #cbd5e1; +} +.accessory-project-caret { font-size: 9px; color: #64748b; flex-shrink: 0; } + +.accessory-btn-commands { + background: #1e2a3a; + border-color: rgba(100, 160, 255, 0.3); + color: #93c5fd; + font-size: 0.75rem; + letter-spacing: 0.03em; +} +.accessory-btn-commands:active { background: #2a3a5a; } + +.accessory-btn-new { + background: #1a2a1a; + border-color: rgba(34, 197, 94, 0.3); + color: #4ade80; + font-size: 1.1rem; + font-weight: 400; + padding: 4px 10px; + line-height: 1; +} +.accessory-btn-new:active { background: #223322; } + +/* View mode segmented toggle */ +.view-mode-toggle { + display: inline-flex; + align-items: stretch; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + height: 36px; +} + +.view-mode-seg { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + background: #2a2a2a; + border: none; + border-right: 1px solid rgba(255, 255, 255, 0.1); + color: #94a3b8; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.view-mode-seg:last-child { border-right: none; } +.view-mode-seg:active { background: #3a3a3a; } +.view-mode-seg.active { + background: rgba(6, 182, 212, 0.18); + color: #22d3ee; +} +.vmt-label { display: none; } +.vmt-icon { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.75rem; + line-height: 1; +} + +/* Commands drawer */ +.accessory-cmd-drawer { + position: absolute; + bottom: 100%; + left: calc(-8px - var(--safe-area-left, 0px)); + right: calc(-8px - var(--safe-area-right, 0px)); + background: #1e1e1e; + border-top: 1px solid rgba(255, 255, 255, 0.15); + display: flex; + flex-direction: column; + pointer-events: none; + opacity: 0; + transform: translateY(8px); + transition: transform 0.18s ease-out, opacity 0.15s ease-out; + max-height: 40vh; + overflow-y: auto; +} + +.accessory-cmd-drawer.open { + pointer-events: auto; + opacity: 1; + transform: translateY(0); +} + +.accessory-cmd-search { + display: block; + width: 100%; + padding: 11px 16px; + background: #111; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + color: #e5e5e5; + font-size: 16px; + outline: none; + box-sizing: border-box; +} +.accessory-cmd-search::placeholder { color: #555; } +.accessory-cmd-search:focus { background: #181818; } + +.accessory-cmd-items { + overflow-y: auto; + max-height: calc(40vh - 44px); +} + +.accessory-drawer-item { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 10px 20px; + background: none; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + text-align: left; + cursor: pointer; +} + +.drawer-cmd-name { + color: #e5e5e5; + font-size: 0.9rem; + font-family: monospace; +} + +.drawer-cmd-desc { + color: #6b7280; + font-size: 0.75rem; + margin-top: 1px; +} + +.accessory-drawer-item:last-child { border-bottom: none; } +.accessory-drawer-item:active { background: #2a2a2a; } + /* Compose / Input Panel */ .mobile-input-panel { position: fixed; From 99b52b2a42c0fe84e5dcc4666e25b461597b38f3 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Sat, 25 Apr 2026 11:02:46 +0000 Subject: [PATCH 03/23] fix(board): add case dropdown to new work item dialog Work items created from the Board UI had no caseId, so the orchestrator could never dispatch them. Add a required Case dropdown to the creation dialog that: - Fetches cases from /api/cases (with fallback if app.cases not loaded) - Pre-selects the current toolbar case - Sends caseId in the POST body Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-04-25-board-case-id-design.md | 43 +++++++++++++++++++ src/web/public/app.js | 26 +++++++++++ 2 files changed, 69 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-25-board-case-id-design.md diff --git a/docs/superpowers/specs/2026-04-25-board-case-id-design.md b/docs/superpowers/specs/2026-04-25-board-case-id-design.md new file mode 100644 index 00000000..c57602d1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-board-case-id-design.md @@ -0,0 +1,43 @@ +# Board Work Item Creation — Case ID Assignment + +**Date**: 2026-04-25 +**Status**: Approved +**Scope**: Single-file change (app.js) + +## Problem + +Work items created from the Board UI via `openNewItemDialog()` never include a `caseId`. The orchestrator (orchestrator.ts:282-286) requires a non-null `caseId` to dispatch items, so UI-created items are permanently orphaned in the queue. + +## Solution + +Add a required "Case" `` populated with case names +3. Pre-select the currently active case from the toolbar (`localStorage.getItem('lastUsedCase')` or the toolbar's case selector value) +4. Include the selected value as `caseId` in the `POST /api/work-items` request body +5. No empty/none option — a case is required for the item to be dispatched + +### Placement + +The Case dropdown goes between the Title field and the Description textarea, matching the existing form layout style. + +### Data + +- Cases come from `GET /api/cases`, same endpoint the toolbar case selector uses +- `caseId` is the case directory name (string), not a numeric ID +- The REST API already accepts `caseId` — no backend changes needed + +## Files Changed + +| File | Change | +|------|--------| +| `src/web/public/app.js` | Add case `` dropdown to the new-item dialog in `openNewItemDialog()` (app.js ~line 23919). + +### Behavior + +1. On dialog open, fetch available cases from `GET /api/cases` +2. Build a `` to `openNewItemDialog()`, include `caseId` in POST body | + +## Out of Scope + +- Backend changes (API already supports `caseId`) +- External integration flows (Clockwork, Asana, etc. set `caseId` via API directly) +- Board filtering by case (separate concern) diff --git a/src/web/public/app.js b/src/web/public/app.js index 413e346a..ca179e3b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -23953,6 +23953,31 @@ const BoardView = { titleInput.id = 'nwi-title'; dlg.appendChild(makeField('Title *', titleInput)); + // Case selector — required for orchestrator dispatch + const caseSelect = document.createElement('select'); + caseSelect.id = 'nwi-caseId'; + const caseField = makeField('Case *', caseSelect); + dlg.appendChild(caseField); + // Populate async — app.cases may already be loaded, otherwise fetch + (async () => { + let cases = app.cases; + if (!cases || !cases.length) { + try { + const res = await fetch('/api/cases'); + cases = await res.json(); + } catch { cases = []; } + } + cases.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.name; + opt.textContent = c.name; + caseSelect.appendChild(opt); + }); + // Pre-select current toolbar case + const toolbarCase = document.getElementById('quickStartCase')?.value; + if (toolbarCase) caseSelect.value = toolbarCase; + })(); + const descInput = document.createElement('textarea'); descInput.placeholder = 'Description'; descInput.id = 'nwi-description'; @@ -24014,6 +24039,7 @@ const BoardView = { title, description: descInput.value.trim() || undefined, source: sourceSelect.value, + caseId: caseSelect.value || undefined, externalRef: extRefInput.value.trim() || undefined, externalUrl: extUrlInput.value.trim() || undefined, }; From c66793dd4cd92f4806cfa6563a4658a3d922aeb5 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Sat, 25 Apr 2026 11:45:30 +0000 Subject: [PATCH 06/23] fix(orchestrator): improve dispatch prompt, fix session naming, prevent phantom projects Three fixes to the orchestrator's work item dispatch: 1. Include externalRef and externalUrl in the prompt sent to Claude, so issue links and references aren't silently dropped. 2. Name dispatched sessions after the work item title instead of the branch name, for easier tracking in the session list. 3. Move dispatch worktrees to ~/.codeman/dispatch-worktrees/ instead of creating sibling directories in ~/codeman-cases/ which appeared as phantom projects in the case list. 4. Use writeViaMux() instead of sendInput() to deliver the prompt. sendInput() calls runPrompt() which fails with "Session already has a running process" because the interactive PTY is already running. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 98 ++++++++++++++++++++++++++++++++++++++++++--- package-lock.json | 4 +- src/orchestrator.ts | 20 +++++++-- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3adee21e..ffe223a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,93 @@ -You are working autonomously in a Codeman worktree. -Before doing ANYTHING else, re-read `TASK.md` in this directory -and resume from the phase in `status`. -Do not rely on conversation history. -Then invoke the codeman-task-runner skill. +# Codeman — Developer Guide + +## Project Overview +- **Name**: Codeman (npm: `aicodeman`) +- **Description**: Control plane for AI coding agents — real-time monitoring, multi-session dashboard, mobile-first UI +- **Tech Stack**: TypeScript backend (Fastify 5), vanilla JS/CSS frontend, tmux for session muxing +- **Repo**: Fork at `maupet/Codeman`, upstream at `SGudbrandsson/Codeman` +- **Version**: 0.6.6 + +## Commands + +```bash +npm run build # Build everything (TS + frontend assets) → dist/ +npm run dev # Dev mode with tsx (live reload) +npm start # Run built app: node dist/index.js +npm run web # Run web server: node dist/index.js web +npm test # Run tests (vitest) +npm run test:watch # Tests in watch mode +npm run typecheck # TypeScript type checking (tsc --noEmit) +npm run lint # ESLint on src/**/*.ts +npm run lint:fix # ESLint with auto-fix +npm run format:check # Prettier check +npm run format # Prettier write +``` + +### Running a dev instance +```bash +node dist/index.js web --port 3001 # Run on non-default port +``` + +## Architecture + +``` +src/ +├── index.ts # Entry point +├── cli.ts # CLI command definitions (commander) +├── web/ +│ ├── server.ts # Fastify web server +│ └── public/ # Frontend (vanilla JS/CSS, no framework) +│ ├── index.html # Main HTML +│ ├── styles.css # Desktop styles +│ ├── mobile.css # Mobile/tablet styles (<1024px) +│ ├── app.js # Main app logic +│ ├── keyboard-accessory.js # Bottom bar / accessory buttons +│ ├── mobile-handlers.js # Touch/mobile detection +│ └── ... +├── session-manager.ts # Claude/OpenCode session lifecycle +├── orchestrator.ts # Session health monitoring +├── respawn-controller.ts # Auto-respawn logic (Ralph loops) +├── mux-factory.ts # tmux backend abstraction +├── vault/ # Session persistence / token tracking +├── config/ # App configuration +├── integrations/ # External service connectors +└── utils/ # Shared utilities +``` + +## Key Patterns + +### Frontend +- **No framework** — vanilla JS with manual DOM manipulation +- **CSS breakpoints** in `mobile.css`: + - `<430px` — phone + - `430-768px` — tablet + - `768-1023px` — large tablet + - `>=1024px` — desktop (uses `styles.css` only) +- `mobile.css` only loads for `<1024px` screens; global styles outside `@media` blocks apply to all mobile/tablet sizes +- **Keyboard accessory bar** — bottom action bar, built programmatically in `keyboard-accessory.js` +- Build step minifies CSS and injects content hashes into `index.html` + +### Backend +- Fastify 5 web server serving static files + WebSocket for real-time updates +- tmux sessions managed via `mux-factory.ts` / `mux-interface.ts` +- CLI uses `commander` with subcommands (`web`, `session`, etc.) +- Default web port: `3000` (configurable via `--port`) + +## Testing +- **Framework**: Vitest +- **Run**: `npm test` (or `npm run test:watch` for dev) +- Test files co-located or in `__tests__/` directories + +## Git Workflow +- Fork: `origin` → `maupet/Codeman` (GitHub) +- Upstream: `upstream` → `SGudbrandsson/Codeman` (GitHub) +- Branch naming: `fix/`, `feat/` +- PRs go from fork branches → upstream `master` +- Use `gh` CLI for GitHub operations (authenticated via `gh auth`) + +## Gotchas +- The upstream default branch is `master`, not `main` +- Build output goes to `dist/` — always rebuild after source changes +- CSS changes require `npm run build` then browser hard-refresh +- For quick CSS hotfixes to production: copy `dist/web/public/.css` directly +- The `CLAUDE.md` in this repo is also used by Codeman's worktree task runner — don't remove the worktree instructions if they exist diff --git a/package-lock.json b/package-lock.json index af4a1235..3a758689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "0.6.4", + "version": "0.6.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "0.6.4", + "version": "0.6.6", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 2654b5ec..d0981659 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -573,7 +573,11 @@ Which agent should handle this? Return JSON only: { "agentId": "...", "reasoning const prefix = item.source === 'github' ? 'fix' : 'feat'; const branch = `${prefix}/${item.id}-${slug}`; const mainGitRoot = (await findMainGitRoot(casePath)) ?? gitRoot; - const worktreePath = join(dirname(mainGitRoot), `${mainGitRoot.split('/').pop()}-${branch.replace(/\//g, '-')}`); + // Put dispatch worktrees in ~/.codeman/dispatch-worktrees/ to avoid + // phantom projects appearing in the case list. + const dispatchDir = join(homedir(), '.codeman', 'dispatch-worktrees'); + await fs.mkdir(dispatchDir, { recursive: true }); + const worktreePath = join(dispatchDir, `${mainGitRoot.split('/').pop()}-${branch.replace(/\//g, '-')}`); try { await addWorktree(gitRoot, worktreePath, branch, true); @@ -607,7 +611,7 @@ Which agent should handle this? Return JSON only: { "agentId": "...", "reasoning const newSession = new Session({ workingDir: worktreePath, mode: 'claude', - name: branch, + name: item.title, mux: this.deps.mux, useMux: true, niceConfig, @@ -651,15 +655,23 @@ Which agent should handle this? Return JSON only: { "agentId": "...", "reasoning if (item.description) { promptParts.push(item.description); } + if (item.externalRef) { + promptParts.push(`Reference: ${item.externalRef}`); + } + if (item.externalUrl) { + promptParts.push(`Details: ${item.externalUrl}`); + } promptParts.push(`\nWorking directory: ${worktreePath}`); promptParts.push(`Branch: ${branch}`); promptParts.push(`\nWhen you are done, output: TASK COMPLETE`); const prompt = promptParts.join('\n\n'); - // Wait a moment for Claude to initialize before sending input + // Wait for Claude to initialize, then type the prompt into the + // interactive session via tmux (writeViaMux), not runPrompt which + // would fail because the interactive PTY is already running. await new Promise((resolve) => setTimeout(resolve, 3000)); - newSession.sendInput(prompt); + await newSession.writeViaMux(prompt + '\r'); } catch (err) { console.error(`[orchestrator] session start failed for ${item.id}:`, getErrorMessage(err)); // Revert work item — don't rely on 5-min dispatch recovery From 60a47cb25248678f896e6f0e16cf9e71c194ad98 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 18:46:48 +0000 Subject: [PATCH 07/23] docs: add PWA upgrade design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec for closing PWA gaps — icons, iOS meta tags, offline app shell caching, session list snapshot, and SW update toast prompt. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-04-27-pwa-upgrade-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-pwa-upgrade-design.md diff --git a/docs/superpowers/specs/2026-04-27-pwa-upgrade-design.md b/docs/superpowers/specs/2026-04-27-pwa-upgrade-design.md new file mode 100644 index 00000000..2fbb79fa --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-pwa-upgrade-design.md @@ -0,0 +1,187 @@ +# PWA Upgrade — Design Spec + +**Date:** 2026-04-27 +**Status:** Draft +**Goal:** Close the gaps between Codeman's minimal PWA and a fully installable, offline-capable progressive web app — without adding a bundler or framework. + +## Context + +Codeman has a basic PWA: a minimal `manifest.json` (name + colors only) and a `sw.js` that handles Web Push notifications. Compared to a full PWA (benchmarked against PressHERO CRM), it's missing: installability (no icons), iOS support (no meta tags), offline app shell caching, cached session snapshots, and a service worker update prompt. + +Codeman is a real-time monitoring dashboard — useless without a WebSocket connection for live data. Full offline support (like PressHERO's CRUD caching) doesn't apply. But caching the app shell + a read-only session list snapshot provides a useful "last known state" experience on flaky networks. + +## Decisions + +- **Offline strategy:** App shell cache + NetworkFirst session list snapshot (option B from brainstorming) +- **Icons:** Generate PNGs from the existing SVG lightning bolt design using `sharp` (already a dependency) +- **Build tooling:** Hand-written Cache API in `sw.js`, no Workbox/Vite (matches the no-framework philosophy) +- **Update prompt:** Toast notification using the existing toast system (non-intrusive, fits the UI) + +## 1. Icons & Manifest + +### Icons + +Source SVG committed at `src/web/public/icons/icon.svg` — the existing lightning bolt on dark rounded rect. + +Generated at build time by `scripts/build.mjs` using `sharp`: + +| File | Size | Purpose | +|------|------|---------| +| `icons/icon-192x192.png` | 192×192 | Standard PWA icon | +| `icons/icon-512x512.png` | 512×512 | Standard PWA icon | +| `icons/icon-maskable-192x192.png` | 192×192 | Android adaptive (80% center, bg fill) | +| `icons/icon-maskable-512x512.png` | 512×512 | Android adaptive (80% center, bg fill) | +| `icons/apple-touch-icon-180x180.png` | 180×180 | iOS home screen | + +Maskable variants render the SVG at ~80% size centered on `#0a0a0a` background, providing the safe zone padding Android adaptive icons require. + +Output goes to `dist/web/public/icons/`. The `src/web/public/icons/` directory contains only the source SVG; PNGs are build artifacts. + +### Manifest + +Expand `src/web/public/manifest.json`: + +```json +{ + "name": "Codeman", + "short_name": "Codeman", + "description": "AI coding agent control plane — real-time monitoring dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "scope": "/", + "id": "/", + "lang": "en", + "icons": [ + { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} +``` + +No screenshots — Codeman is a private tool, not an app store listing. + +## 2. iOS Meta Tags + +Add to `index.html` `` after the existing ``: + +```html + + + +``` + +Existing tags (`theme-color`, `description`, `viewport`, manifest link) are already correct. + +## 3. Service Worker — Offline Caching + +Rewrite `sw.js` to add caching while preserving the existing push notification handlers (`push`, `notificationclick`). + +### Precache (install event) + +Hand-maintained `PRECACHE_URLS` array of app shell assets: + +``` +/, /styles.css, /mobile.css, /app.js, +/keyboard-accessory.js, /mobile-handlers.js, /constants.js, +/manifest.json, /icons/icon-192x192.png, +/vendor/xterm.min.js, /vendor/xterm.css, +/vendor/xterm-addon-fit.min.js, /vendor/xterm-addon-webgl.min.js, +/vendor/xterm-addon-unicode11.min.js, /vendor/xterm-addon-search.min.js +``` + +Cache name: `codeman-shell-v1`. Bump the version manually when assets change. Old caches purged in `activate` event. + +### Runtime — session list snapshot (fetch event) + +NetworkFirst for `/api/sessions` only: +- Try network with 5-second timeout +- Fall back to cached response if network fails +- Add `X-Codeman-Cached: true` header to cached responses so the app can detect stale data +- Cache name: `codeman-api-v1` + +### Everything else (fetch event) + +- Precached assets → cache-first (fast, served from cache) +- WebSocket upgrades, other API calls, push endpoints → network-only (pass through, never cache) + +### Message handler (for SW updates) + +Listen for `{ type: 'SKIP_WAITING' }` messages and call `self.skipWaiting()`. + +## 4. Offline UI State + +### Connection state + +No changes needed — existing WebSocket reconnect logic already communicates connection drops. + +### Stale data indicator + +When `/api/sessions` response has the `X-Codeman-Cached: true` header, display a muted label below the session list: "Last updated: {time}". + +The timestamp comes from the `Date` header on the cached response (set by the browser when the response was originally fetched). + +No toast, no modal — just a quiet label so you know you're looking at a snapshot. + +## 5. SW Update Prompt + +### Detection + +In `app.js` `registerServiceWorker()`, add an `updatefound` listener on the registration. When the new SW reaches `installed` state and there's an existing controller (meaning it's an update, not first install), show the toast. + +### Prompt + +Use the existing `showToast()` with its `action` option (already supports `{ label, onClick }` and custom `duration`): "New version available — tap to update". Pass `duration: 86400000` for no practical auto-dismiss. This matches the existing `_onUpdateAvailable()` pattern used for npm package updates. + +### Reload flow + +1. User taps the toast +2. App posts `{ type: 'SKIP_WAITING' }` to the waiting SW +3. App listens for `controllerchange` on `navigator.serviceWorker` +4. On controller change, `window.location.reload()` + +## 6. Build Script Changes + +Add one step to `scripts/build.mjs` after "copy web assets": + +1. Read `src/web/public/icons/icon.svg` +2. Use `sharp` to render 5 PNGs (192, 512, maskable-192, maskable-512, apple-180) +3. Output to `dist/web/public/icons/` + +Maskable variants: render SVG at ~80% size centered on `#0a0a0a` background. + +No other build changes. The precache list is hand-maintained in `sw.js` source, content-hash injection handles browser cache busting, and the SW cache is versioned separately via `CACHE_VERSION`. + +## 7. Files Modified + +| File | Change | +|------|--------| +| `src/web/public/manifest.json` | Expand with icons, description, scope, id, lang | +| `src/web/public/index.html` | Add iOS meta tags + apple-touch-icon link | +| `src/web/public/sw.js` | Add precache, fetch handler, SKIP_WAITING listener | +| `src/web/public/app.js` | Add SW update detection + toast, stale data indicator | +| `src/web/public/icons/icon.svg` | New — source SVG for icon generation | +| `scripts/build.mjs` | Add sharp-based icon generation step | + +## 8. Testing + +Manual verification (browser/PWA behavior, no automated tests): + +1. **Installability** — Chrome DevTools > Application > Manifest shows no errors, install prompt appears +2. **Icons** — All 5 PNGs render correctly, maskable icons pass safe zone check in DevTools +3. **iOS** — Safari "Add to Home Screen" shows Apple touch icon, app launches standalone +4. **Offline shell** — Kill server, reload → app shell loads from cache, shows connecting state +5. **Session snapshot** — With sessions running, go offline → session list shows last-known state with "Last updated" label +6. **SW update** — Change `CACHE_VERSION`, rebuild, reload → toast appears +7. **Push preserved** — Existing push notification flow unaffected + +## Non-Goals + +- No Workbox or Vite integration +- No offline write/action support (monitoring tool — actions require live connection) +- No app store screenshots +- No background sync +- No IndexedDB storage From ebef14fdf409348e0f631deca32d1a29a2da3111 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 18:50:38 +0000 Subject: [PATCH 08/23] docs: add PWA upgrade implementation plan 6-task plan covering icons, manifest, iOS meta tags, SW rewrite with precache/offline caching, update toast, and stale data indicator. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-27-pwa-upgrade.md | 659 ++++++++++++++++++ 1 file changed, 659 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-pwa-upgrade.md diff --git a/docs/superpowers/plans/2026-04-27-pwa-upgrade.md b/docs/superpowers/plans/2026-04-27-pwa-upgrade.md new file mode 100644 index 00000000..bfa1410a --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-pwa-upgrade.md @@ -0,0 +1,659 @@ +# PWA Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Codeman a fully installable PWA with offline app shell caching, a session list snapshot for flaky networks, iOS support, and a service worker update prompt. + +**Architecture:** Hand-written Cache API in `sw.js` (no Workbox). Precache the app shell on install, NetworkFirst for `/api/sessions`, cache-first for static assets, network-only for everything else. Icons generated at build time from source SVG using `sharp`. + +**Tech Stack:** Vanilla JS, Cache API, sharp (existing dependency), `scripts/build.mjs` + +**Spec:** `docs/superpowers/specs/2026-04-27-pwa-upgrade-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/web/public/icons/icon.svg` | Create | Source SVG for icon generation | +| `src/web/public/manifest.json` | Modify | Add icons, description, scope, id, lang | +| `src/web/public/index.html` | Modify | Add iOS meta tags + apple-touch-icon link | +| `src/web/public/sw.js` | Rewrite | Add precache, fetch handler, SKIP_WAITING, keep push handlers | +| `src/web/public/app.js` | Modify | SW update detection + toast, stale data indicator on session fetch | +| `scripts/build.mjs` | Modify | Add sharp-based icon generation step | + +--- + +### Task 1: Source SVG & Icon Build Step + +**Files:** +- Create: `src/web/public/icons/icon.svg` +- Modify: `scripts/build.mjs` + +- [ ] **Step 1: Create the source SVG** + +Create `src/web/public/icons/icon.svg` — the existing lightning bolt design extracted from the inline data URI in `index.html`: + +```svg + + + + + + + + + + +``` + +This is the same design as the inline favicon but scaled to 512×512 viewBox. The `rx="96"` gives the same proportional rounding as `rx="6"` on the 32×32 original. + +- [ ] **Step 2: Add icon generation to build script** + +In `scripts/build.mjs`, add this block after the "copy web assets" step (after line 34, before the vendor xterm section): + +```js +// 2b. Generate PWA icons from source SVG +{ + const sharp = (await import('sharp')).default; + const svgPath = join(ROOT, 'src/web/public/icons/icon.svg'); + const outDir = join(ROOT, 'dist/web/public/icons'); + execSync(`mkdir -p "${outDir}"`, { cwd: ROOT, shell: true }); + + const svgBuf = readFileSync(svgPath); + + // Standard icons — full bleed + for (const size of [192, 512]) { + await sharp(svgBuf).resize(size, size).png().toFile(join(outDir, `icon-${size}x${size}.png`)); + } + + // Maskable icons — 80% center on background + for (const size of [192, 512]) { + const inner = Math.round(size * 0.8); + const pad = Math.round((size - inner) / 2); + const icon = await sharp(svgBuf).resize(inner, inner).png().toBuffer(); + await sharp({ + create: { width: size, height: size, channels: 4, background: { r: 10, g: 10, b: 10, alpha: 1 } }, + }).composite([{ input: icon, left: pad, top: pad }]).png().toFile(join(outDir, `icon-maskable-${size}x${size}.png`)); + } + + // Apple touch icon — 180x180, full bleed + await sharp(svgBuf).resize(180, 180).png().toFile(join(outDir, 'apple-touch-icon-180x180.png')); + + console.log('[build] generate PWA icons — done'); +} +``` + +- [ ] **Step 3: Run the build and verify icons are generated** + +Run: `npm run build` + +Verify: `ls -la dist/web/public/icons/` + +Expected: 5 PNG files: +``` +icon-192x192.png +icon-512x512.png +icon-maskable-192x192.png +icon-maskable-512x512.png +apple-touch-icon-180x180.png +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/web/public/icons/icon.svg scripts/build.mjs +git commit -m "feat(pwa): add source SVG and icon generation build step" +``` + +--- + +### Task 2: Manifest & iOS Meta Tags + +**Files:** +- Modify: `src/web/public/manifest.json` +- Modify: `src/web/public/index.html` + +- [ ] **Step 1: Update the manifest** + +Replace the contents of `src/web/public/manifest.json` with: + +```json +{ + "name": "Codeman", + "short_name": "Codeman", + "description": "AI coding agent control plane — real-time monitoring dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "scope": "/", + "id": "/", + "lang": "en", + "icons": [ + { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} +``` + +- [ ] **Step 2: Add iOS meta tags to index.html** + +In `src/web/public/index.html`, after line 8 (``), add: + +```html + + + +``` + +- [ ] **Step 3: Build and verify** + +Run: `npm run build` + +Verify in `dist/web/public/index.html` that the iOS meta tags are present and the manifest link still works. + +- [ ] **Step 4: Commit** + +```bash +git add src/web/public/manifest.json src/web/public/index.html +git commit -m "feat(pwa): expand manifest with icons, add iOS meta tags" +``` + +--- + +### Task 3: Service Worker — Precache & Fetch Handler + +**Files:** +- Rewrite: `src/web/public/sw.js` + +- [ ] **Step 1: Rewrite sw.js with caching + existing push handlers** + +Replace the entire contents of `src/web/public/sw.js` with: + +```js +/** + * @fileoverview Codeman service worker — offline caching + Web Push notifications. + * + * Caching strategy: + * - Install: precache app shell assets (HTML, CSS, JS, vendor libs) + * - Fetch: cache-first for precached assets, NetworkFirst for /api/sessions, + * network-only for everything else (WebSocket, other APIs, push endpoints) + * - Activate: purge old caches + * + * Push notifications (unchanged from original): + * - Receives push events and displays OS-level notifications + * - Handles notification clicks to focus/open Codeman tab + * + * Update flow: + * - Listens for SKIP_WAITING message from app.js to activate immediately + */ + +const CACHE_VERSION = 1; +const SHELL_CACHE = `codeman-shell-v${CACHE_VERSION}`; +const API_CACHE = `codeman-api-v${CACHE_VERSION}`; + +const PRECACHE_URLS = [ + '/', + '/styles.css', + '/mobile.css', + '/constants.js', + '/feature-registry.js', + '/feature-tracker.js', + '/mobile-handlers.js', + '/voice-input.js', + '/notification-manager.js', + '/secret-detector.js', + '/keyboard-accessory.js', + '/app.js', + '/ralph-wizard.js', + '/api-client.js', + '/subagent-windows.js', + '/manifest.json', + '/icons/icon-192x192.png', + '/vendor/xterm.min.js', + '/vendor/xterm.css', + '/vendor/xterm-addon-fit.min.js', + '/vendor/xterm-addon-webgl.min.js', + '/vendor/xterm-addon-unicode11.min.js', + '/vendor/xterm-addon-search.min.js', +]; + +// ═══════════════════════════════════════════════════════════════ +// Lifecycle +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(SHELL_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== SHELL_CACHE && k !== API_CACHE) + .map((k) => caches.delete(k)) + ) + ).then(() => self.clients.claim()) + ); +}); + +// ═══════════════════════════════════════════════════════════════ +// Fetch — caching strategies +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Never intercept non-GET or cross-origin requests + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + + // NetworkFirst for /api/sessions (session list snapshot) + if (url.pathname === '/api/sessions') { + event.respondWith(networkFirstSessions(request)); + return; + } + + // Cache-first for precached app shell assets + if (isPrecached(url.pathname)) { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request)) + ); + return; + } + + // Everything else: network-only (WebSocket, other APIs, push endpoints) +}); + +function isPrecached(pathname) { + return PRECACHE_URLS.includes(pathname); +} + +async function networkFirstSessions(request) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(request, { signal: controller.signal }); + clearTimeout(timeoutId); + // Cache successful responses + if (response.ok) { + const cache = await caches.open(API_CACHE); + cache.put(request, response.clone()); + } + return response; + } catch { + clearTimeout(timeoutId); + // Network failed — serve from cache with stale marker + const cached = await caches.match(request); + if (cached) { + // Clone response and add X-Codeman-Cached header so the app can detect stale data + const headers = new Headers(cached.headers); + headers.set('X-Codeman-Cached', 'true'); + return new Response(cached.body, { + status: cached.status, + statusText: cached.statusText, + headers, + }); + } + // Nothing cached — return network error + return new Response(JSON.stringify({ error: 'offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +// ═══════════════════════════════════════════════════════════════ +// SW Update — SKIP_WAITING message from app.js +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +// ═══════════════════════════════════════════════════════════════ +// Web Push Notifications +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('push', (event) => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + return; + } + + const { title, body, tag, sessionId, urgency, actions } = payload; + + const options = { + body: body || '', + tag: tag || 'codeman-default', + icon: '/icons/icon-192x192.png', + badge: '/icons/icon-192x192.png', + data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' }, + renotify: true, + requireInteraction: urgency === 'critical', + }; + + if (actions && actions.length > 0) { + options.actions = actions; + } + + event.waitUntil( + self.registration.showNotification(title || 'Codeman', options) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const { sessionId, url } = event.notification.data || {}; + const targetUrl = url || '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if (client.url.includes(self.location.origin)) { + client.postMessage({ + type: 'notification-click', + sessionId, + action: event.action || null, + }); + return client.focus(); + } + } + return self.clients.openWindow(targetUrl); + }) + ); +}); +``` + +Key changes from the original: +- Added `PRECACHE_URLS`, `SHELL_CACHE`, `API_CACHE` constants +- `install` event now precaches the app shell (instead of just `skipWaiting`) +- `activate` event purges old caches and claims clients +- New `fetch` event handler with cache-first for shell, NetworkFirst for sessions +- New `message` handler for `SKIP_WAITING` +- Push `icon`/`badge` now point to the real PNG instead of `/favicon.ico` +- Note: removed the unconditional `self.skipWaiting()` from install — the new SW waits until the app tells it to activate (via the SKIP_WAITING message), so active sessions aren't disrupted + +- [ ] **Step 2: Build and verify the SW is copied to dist** + +Run: `npm run build` + +Verify: `head -5 dist/web/public/sw.js` shows the new file header. + +- [ ] **Step 3: Commit** + +```bash +git add src/web/public/sw.js +git commit -m "feat(pwa): rewrite service worker with precache and offline session snapshot" +``` + +--- + +### Task 4: SW Update Toast in app.js + +**Files:** +- Modify: `src/web/public/app.js` + +- [ ] **Step 1: Update registerServiceWorker() with update detection** + +In `src/web/public/app.js`, replace the `registerServiceWorker()` method (lines 13277–13301) with: + +```js + registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('/sw.js').then((reg) => { + this._swRegistration = reg; + + // Listen for messages from service worker (notification clicks) + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type === 'notification-click') { + const { sessionId } = event.data; + if (sessionId && this.sessions.has(sessionId)) { + this.selectSession(sessionId); + } + window.focus(); + } + }); + + // SW update detection — show toast when new version is waiting + reg.addEventListener('updatefound', () => { + const newWorker = reg.installing; + if (!newWorker) return; + newWorker.addEventListener('statechange', () => { + // Only show toast for updates (not first install) + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + this._showSwUpdateToast(newWorker); + } + }); + }); + + // Listen for controller change (after SKIP_WAITING) and reload + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (this._swReloading) return; + this._swReloading = true; + window.location.reload(); + }); + + // Check if already subscribed + reg.pushManager.getSubscription().then((sub) => { + if (sub) { + this._pushSubscription = sub; + this._updatePushUI(true); + } + }); + }).catch(() => { + // Service worker registration failed (likely not HTTPS) + }); + } + + _showSwUpdateToast(waitingWorker) { + if (this._swUpdateToastShown) return; + this._swUpdateToastShown = true; + this.showToast('New version available — tap to update', 'info', { + duration: 86400000, + action: { + label: 'Reload', + onClick: () => waitingWorker.postMessage({ type: 'SKIP_WAITING' }), + }, + }); + } +``` + +This preserves all existing behavior (notification click handling, push subscription check) and adds: +- `updatefound` listener on the registration +- `statechange` listener on the installing worker +- Toast shown only for updates (checks `navigator.serviceWorker.controller` exists) +- `controllerchange` listener for reload after SKIP_WAITING +- Guard `_swReloading` prevents double-reload + +- [ ] **Step 2: Build and verify** + +Run: `npm run build` + +Verify: `grep '_showSwUpdateToast' dist/web/public/app.js` returns a match. + +- [ ] **Step 3: Commit** + +```bash +git add src/web/public/app.js +git commit -m "feat(pwa): add service worker update detection and toast prompt" +``` + +--- + +### Task 5: Stale Session Data Indicator + +**Files:** +- Modify: `src/web/public/app.js` + +- [ ] **Step 1: Find the session-fetching code paths and add stale detection** + +There are two places in `app.js` that need the stale indicator. The main one is the initial session hydration from SSE/WebSocket. However, the session list in Codeman is hydrated via SSE events (`session-list`, `session-add`, etc.), not via a standalone `/api/sessions` fetch in the main app flow. The `/api/sessions` fetch calls at lines 23804 and 24216 are in the board module, not the main session drawer. + +The right approach: add a dedicated fetch to `/api/sessions` as a fallback when the SSE connection is down. But the app already handles SSE reconnection. The stale indicator should show when we detect we're offline and the SW served cached data. + +Add this method to the `CodemanApp` class, after the `registerServiceWorker()` / `_showSwUpdateToast()` methods: + +```js + /** + * Check if the last /api/sessions response came from the SW cache. + * Called after any fetch to /api/sessions to update the stale data indicator. + */ + _checkStaleSessionData(response) { + const isCached = response.headers.get('X-Codeman-Cached') === 'true'; + const indicator = document.getElementById('staleDataIndicator'); + if (isCached) { + const dateHeader = response.headers.get('Date'); + const timeStr = dateHeader ? new Date(dateHeader).toLocaleTimeString() : 'unknown'; + if (indicator) { + indicator.textContent = `Offline — last updated: ${timeStr}`; + indicator.style.display = ''; + } else { + this._createStaleIndicator(`Offline — last updated: ${timeStr}`); + } + } else if (indicator) { + indicator.style.display = 'none'; + } + } + + _createStaleIndicator(text) { + const el = document.createElement('div'); + el.id = 'staleDataIndicator'; + el.textContent = text; + el.style.cssText = 'padding:4px 12px;font-size:11px;color:#666;text-align:center;background:#111;border-bottom:1px solid #1a1a2e'; + // Insert at top of session drawer list + const drawer = document.querySelector('.session-drawer-list'); + if (drawer) { + drawer.parentNode.insertBefore(el, drawer); + } + } +``` + +- [ ] **Step 2: Wire up stale detection on the board's session fetch** + +In `app.js`, find the board refresh method at line ~24216: + +```js + fetch('/api/sessions').then(r => r.ok ? r.json() : []), +``` + +Replace the `/api/sessions` fetch in the board refresh with a version that checks for stale data. Find the line: + +```js + fetch('/api/sessions').then(r => r.ok ? r.json() : []), +``` + +Replace with: + +```js + fetch('/api/sessions').then(r => { if (typeof app !== 'undefined') app._checkStaleSessionData(r); return r.ok ? r.json() : []; }), +``` + +- [ ] **Step 3: Build and verify** + +Run: `npm run build` + +Verify: `grep 'staleDataIndicator' dist/web/public/app.js` returns matches. + +- [ ] **Step 4: Commit** + +```bash +git add src/web/public/app.js +git commit -m "feat(pwa): add offline stale session data indicator" +``` + +--- + +### Task 6: Build, Test & Final Commit + +**Files:** +- All modified files from Tasks 1–5 + +- [ ] **Step 1: Full clean build** + +```bash +npm run clean && npm run build +``` + +Expected: Build completes with no errors, including the new "generate PWA icons" step. + +- [ ] **Step 2: Verify all artifacts** + +```bash +# Icons generated +ls -la dist/web/public/icons/ + +# Manifest has icons +cat dist/web/public/manifest.json | grep -c icon + +# iOS meta tags present +grep 'apple-mobile-web-app-capable' dist/web/public/index.html + +# SW has precache +grep 'PRECACHE_URLS' dist/web/public/sw.js + +# SW update toast wired +grep '_showSwUpdateToast' dist/web/public/app.js + +# Stale indicator wired +grep 'staleDataIndicator' dist/web/public/app.js +``` + +Expected: All commands produce output (no empty results). + +- [ ] **Step 3: Run existing tests** + +```bash +npm test +``` + +Expected: All existing tests pass (this change doesn't touch any tested backend code). + +- [ ] **Step 4: Run lint and typecheck** + +```bash +npm run typecheck && npm run lint +``` + +Expected: No new errors (only `scripts/build.mjs` and frontend JS files were changed, neither is type-checked or linted by the current config). + +- [ ] **Step 5: Manual PWA verification** + +Start a dev instance and verify in Chrome DevTools: + +```bash +node dist/index.js web --port 3001 +``` + +Open `http://localhost:3001` in Chrome, then: + +1. DevTools → Application → Manifest: should show all icon sizes, no errors +2. DevTools → Application → Service Workers: should show the new SW as active +3. DevTools → Application → Cache Storage: should show `codeman-shell-v1` with all precached assets +4. Network tab → throttle to Offline → reload: app shell loads from cache +5. Check session list shows stale indicator when offline + +- [ ] **Step 6: Final commit (if any uncommitted changes from verification)** + +```bash +git status +# If clean, skip. If there are fixes from testing: +git add -u +git commit -m "fix(pwa): address issues found during verification" +``` From 024db954c027f5a28abd0b1dce71b2c6517387fb Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 19:09:21 +0000 Subject: [PATCH 09/23] feat(pwa): add source SVG and icon generation build step --- scripts/build.mjs | 30 ++++++++++++++++++++++++++++++ src/web/public/icons/icon.svg | 10 ++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/web/public/icons/icon.svg diff --git a/scripts/build.mjs b/scripts/build.mjs index 7981ae82..58dcce3d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -34,6 +34,36 @@ run('prepare dirs', 'mkdir -p dist/web dist/templates'); run('copy web assets', 'rm -rf dist/web/public && cp -r src/web/public dist/web/ && mkdir -p dist/web/public/vendor'); run('copy template', 'cp src/templates/case-template.md dist/templates/'); +// 2b. Generate PWA icons from source SVG +await (async () => { + const sharp = (await import('sharp')).default; + const svgPath = join(ROOT, 'src/web/public/icons/icon.svg'); + const outDir = join(ROOT, 'dist/web/public/icons'); + execSync(`mkdir -p "${outDir}"`, { cwd: ROOT, shell: true }); + + const svgBuf = readFileSync(svgPath); + + // Standard icons — full bleed + for (const size of [192, 512]) { + await sharp(svgBuf).resize(size, size).png().toFile(join(outDir, `icon-${size}x${size}.png`)); + } + + // Maskable icons — 80% center on background + for (const size of [192, 512]) { + const inner = Math.round(size * 0.8); + const pad = Math.round((size - inner) / 2); + const icon = await sharp(svgBuf).resize(inner, inner).png().toBuffer(); + await sharp({ + create: { width: size, height: size, channels: 4, background: { r: 10, g: 10, b: 10, alpha: 1 } }, + }).composite([{ input: icon, left: pad, top: pad }]).png().toFile(join(outDir, `icon-maskable-${size}x${size}.png`)); + } + + // Apple touch icon — 180x180, full bleed + await sharp(svgBuf).resize(180, 180).png().toFile(join(outDir, 'apple-touch-icon-180x180.png')); + + console.log('[build] generate PWA icons — done'); +})(); + // 3. Vendor xterm bundles (@xterm/* v6 namespace) run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/'); run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js'); diff --git a/src/web/public/icons/icon.svg b/src/web/public/icons/icon.svg new file mode 100644 index 00000000..482e5b67 --- /dev/null +++ b/src/web/public/icons/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 51cf546f8e733d2ad9db285da26366eb46c7a75e Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 19:11:56 +0000 Subject: [PATCH 10/23] feat(pwa): expand manifest with icons, add iOS meta tags --- src/web/public/index.html | 3 +++ src/web/public/manifest.json | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index dcae3b82..a08b41a5 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -6,6 +6,9 @@ + + + Codeman diff --git a/src/web/public/manifest.json b/src/web/public/manifest.json index 410d4912..be269db9 100644 --- a/src/web/public/manifest.json +++ b/src/web/public/manifest.json @@ -1,8 +1,18 @@ { "name": "Codeman", "short_name": "Codeman", + "description": "AI coding agent control plane — real-time monitoring dashboard", "start_url": "/", "display": "standalone", "background_color": "#0a0a0a", - "theme_color": "#0a0a0a" + "theme_color": "#0a0a0a", + "scope": "/", + "id": "/", + "lang": "en", + "icons": [ + { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] } From e8faba353b08d15c80c9ae72f509bcba7152ca19 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 19:14:01 +0000 Subject: [PATCH 11/23] feat(pwa): rewrite service worker with precache and offline session snapshot --- src/web/public/sw.js | 159 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 19 deletions(-) diff --git a/src/web/public/sw.js b/src/web/public/sw.js index 5d87bbb3..7cee40cd 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -1,30 +1,153 @@ /** - * @fileoverview Service worker for Web Push notifications. + * @fileoverview Codeman service worker — offline caching + Web Push notifications. * - * Receives push events from the Codeman server (via web-push library) and displays - * OS-level notifications. Handles notification clicks to focus an existing Codeman - * tab or open a new one. Supports action buttons, per-session deep linking, and - * critical notification persistence (requireInteraction). + * Caching strategy: + * - Install: precache app shell assets (HTML, CSS, JS, vendor libs) + * - Fetch: cache-first for precached assets, NetworkFirst for /api/sessions, + * network-only for everything else (WebSocket, other APIs, push endpoints) + * - Activate: purge old caches * - * Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest - * service worker takes control immediately without waiting for tab refresh. + * Push notifications (unchanged from original): + * - Receives push events and displays OS-level notifications + * - Handles notification clicks to focus/open Codeman tab * - * @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts) - * @see src/push-store.ts — server-side VAPID key management and subscription CRUD + * Update flow: + * - Listens for SKIP_WAITING message from app.js to activate immediately */ -// Codeman Service Worker — Web Push notifications -// This service worker receives push events from the server and displays OS-level notifications. -// It also handles notification clicks to focus or open the Codeman tab. +const CACHE_VERSION = 1; +const SHELL_CACHE = `codeman-shell-v${CACHE_VERSION}`; +const API_CACHE = `codeman-api-v${CACHE_VERSION}`; -self.addEventListener('install', () => { - self.skipWaiting(); +const PRECACHE_URLS = [ + '/', + '/styles.css', + '/mobile.css', + '/constants.js', + '/feature-registry.js', + '/feature-tracker.js', + '/mobile-handlers.js', + '/voice-input.js', + '/notification-manager.js', + '/secret-detector.js', + '/keyboard-accessory.js', + '/app.js', + '/ralph-wizard.js', + '/api-client.js', + '/subagent-windows.js', + '/manifest.json', + '/icons/icon-192x192.png', + '/vendor/xterm.min.js', + '/vendor/xterm.css', + '/vendor/xterm-addon-fit.min.js', + '/vendor/xterm-addon-webgl.min.js', + '/vendor/xterm-addon-unicode11.min.js', + '/vendor/xterm-addon-search.min.js', +]; + +// ═══════════════════════════════════════════════════════════════ +// Lifecycle +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(SHELL_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) + ); }); self.addEventListener('activate', (event) => { - event.waitUntil(self.clients.claim()); + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== SHELL_CACHE && k !== API_CACHE) + .map((k) => caches.delete(k)) + ) + ).then(() => self.clients.claim()) + ); +}); + +// ═══════════════════════════════════════════════════════════════ +// Fetch — caching strategies +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Never intercept non-GET or cross-origin requests + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + + // NetworkFirst for /api/sessions (session list snapshot) + if (url.pathname === '/api/sessions') { + event.respondWith(networkFirstSessions(request)); + return; + } + + // Cache-first for precached app shell assets + if (isPrecached(url.pathname)) { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request)) + ); + return; + } + + // Everything else: network-only (WebSocket, other APIs, push endpoints) +}); + +function isPrecached(pathname) { + return PRECACHE_URLS.includes(pathname); +} + +async function networkFirstSessions(request) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(request, { signal: controller.signal }); + clearTimeout(timeoutId); + // Cache successful responses + if (response.ok) { + const cache = await caches.open(API_CACHE); + cache.put(request, response.clone()); + } + return response; + } catch { + clearTimeout(timeoutId); + // Network failed — serve from cache with stale marker + const cached = await caches.match(request); + if (cached) { + // Clone response and add X-Codeman-Cached header so the app can detect stale data + const headers = new Headers(cached.headers); + headers.set('X-Codeman-Cached', 'true'); + return new Response(cached.body, { + status: cached.status, + statusText: cached.statusText, + headers, + }); + } + // Nothing cached — return network error + return new Response(JSON.stringify({ error: 'offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +// ═══════════════════════════════════════════════════════════════ +// SW Update — SKIP_WAITING message from app.js +// ═══════════════════════════════════════════════════════════════ + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } }); +// ═══════════════════════════════════════════════════════════════ +// Web Push Notifications +// ═══════════════════════════════════════════════════════════════ + self.addEventListener('push', (event) => { if (!event.data) return; @@ -40,8 +163,8 @@ self.addEventListener('push', (event) => { const options = { body: body || '', tag: tag || 'codeman-default', - icon: '/favicon.ico', - badge: '/favicon.ico', + icon: '/icons/icon-192x192.png', + badge: '/icons/icon-192x192.png', data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' }, renotify: true, requireInteraction: urgency === 'critical', @@ -64,7 +187,6 @@ self.addEventListener('notificationclick', (event) => { event.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { - // Try to find an existing Codeman tab for (const client of clients) { if (client.url.includes(self.location.origin)) { client.postMessage({ @@ -75,7 +197,6 @@ self.addEventListener('notificationclick', (event) => { return client.focus(); } } - // No existing tab — open a new one return self.clients.openWindow(targetUrl); }) ); From ea4f6b9f7576bc2abf8e5e956113f13295b3197d Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 19:17:33 +0000 Subject: [PATCH 12/23] fix(pwa): fix cache-first query string mismatch, await cache.put MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ignoreSearch:true to caches.match — build injects ?v= hashes that prevented cache hits on precached assets - Await cache.put in networkFirstSessions to prevent silent write drops - Add CACHE_VERSION bump reminder comment Co-Authored-By: Claude Opus 4.6 --- src/web/public/sw.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/web/public/sw.js b/src/web/public/sw.js index 7cee40cd..35a65ac0 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -15,6 +15,7 @@ * - Listens for SKIP_WAITING message from app.js to activate immediately */ +// Increment CACHE_VERSION when PRECACHE_URLS changes to trigger cache purge const CACHE_VERSION = 1; const SHELL_CACHE = `codeman-shell-v${CACHE_VERSION}`; const API_CACHE = `codeman-api-v${CACHE_VERSION}`; @@ -85,9 +86,11 @@ self.addEventListener('fetch', (event) => { } // Cache-first for precached app shell assets + // ignoreSearch: true — build injects ?v= query strings for browser cache busting, + // but the SW cache keys are stored without query strings (from cache.addAll) if (isPrecached(url.pathname)) { event.respondWith( - caches.match(request).then((cached) => cached || fetch(request)) + caches.match(request, { ignoreSearch: true }).then((cached) => cached || fetch(request)) ); return; } @@ -109,7 +112,7 @@ async function networkFirstSessions(request) { // Cache successful responses if (response.ok) { const cache = await caches.open(API_CACHE); - cache.put(request, response.clone()); + await cache.put(request, response.clone()); } return response; } catch { From 45a636165d239cc503b11aafe71684e018601d50 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 19:18:43 +0000 Subject: [PATCH 13/23] feat(pwa): add service worker update detection and toast prompt --- src/web/public/app.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/web/public/app.js b/src/web/public/app.js index ca179e3b..277039b1 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -13278,6 +13278,7 @@ class CodemanApp { if (!('serviceWorker' in navigator)) return; navigator.serviceWorker.register('/sw.js').then((reg) => { this._swRegistration = reg; + // Listen for messages from service worker (notification clicks) navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'notification-click') { @@ -13288,6 +13289,26 @@ class CodemanApp { window.focus(); } }); + + // SW update detection — show toast when new version is waiting + reg.addEventListener('updatefound', () => { + const newWorker = reg.installing; + if (!newWorker) return; + newWorker.addEventListener('statechange', () => { + // Only show toast for updates (not first install) + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + this._showSwUpdateToast(newWorker); + } + }); + }); + + // Listen for controller change (after SKIP_WAITING) and reload + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (this._swReloading) return; + this._swReloading = true; + window.location.reload(); + }); + // Check if already subscribed reg.pushManager.getSubscription().then((sub) => { if (sub) { @@ -13300,6 +13321,18 @@ class CodemanApp { }); } + _showSwUpdateToast(waitingWorker) { + if (this._swUpdateToastShown) return; + this._swUpdateToastShown = true; + this.showToast('New version available — tap to update', 'info', { + duration: 86400000, + action: { + label: 'Reload', + onClick: () => waitingWorker.postMessage({ type: 'SKIP_WAITING' }), + }, + }); + } + async subscribeToPush() { if (!this._swRegistration) { this.showToast('Service worker not available. HTTPS or localhost required.', 'error'); From 1ed5d92f20da4dbc1d8fea3a79e80b27660cd98a Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Mon, 27 Apr 2026 19:20:35 +0000 Subject: [PATCH 14/23] feat(pwa): add offline stale session data indicator --- src/web/public/app.js | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 277039b1..2a0c43aa 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -13333,6 +13333,39 @@ class CodemanApp { }); } + /** + * Check if the last /api/sessions response came from the SW cache. + * Called after any fetch to /api/sessions to update the stale data indicator. + */ + _checkStaleSessionData(response) { + const isCached = response.headers.get('X-Codeman-Cached') === 'true'; + const indicator = document.getElementById('staleDataIndicator'); + if (isCached) { + const dateHeader = response.headers.get('Date'); + const timeStr = dateHeader ? new Date(dateHeader).toLocaleTimeString() : 'unknown'; + if (indicator) { + indicator.textContent = `Offline — last updated: ${timeStr}`; + indicator.style.display = ''; + } else { + this._createStaleIndicator(`Offline — last updated: ${timeStr}`); + } + } else if (indicator) { + indicator.style.display = 'none'; + } + } + + _createStaleIndicator(text) { + const el = document.createElement('div'); + el.id = 'staleDataIndicator'; + el.textContent = text; + el.style.cssText = 'padding:4px 12px;font-size:11px;color:#666;text-align:center;background:#111;border-bottom:1px solid #1a1a2e'; + // Insert at top of session drawer list + const drawer = document.querySelector('.session-drawer-list'); + if (drawer) { + drawer.parentNode.insertBefore(el, drawer); + } + } + async subscribeToPush() { if (!this._swRegistration) { this.showToast('Service worker not available. HTTPS or localhost required.', 'error'); @@ -24246,7 +24279,7 @@ const ActionDashboard = { async refresh() { // Fetch all three data sources in parallel const [sessRes, wiRes, wtRes] = await Promise.allSettled([ - fetch('/api/sessions').then(r => r.ok ? r.json() : []), + fetch('/api/sessions').then(r => { if (typeof app !== 'undefined') app._checkStaleSessionData(r); return r.ok ? r.json() : []; }), fetch('/api/work-items').then(r => r.ok ? r.json() : { data: [] }), fetch('/api/worktrees').then(r => r.ok ? r.json() : []), ]); From 78d34f65c09486ef8266bb96fa01051bbd744fb1 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 28 Apr 2026 08:23:30 +0000 Subject: [PATCH 15/23] =?UTF-8?q?fix(pwa):=20resilient=20precache=20?= =?UTF-8?q?=E2=80=94=20skip=20auth=20failures=20instead=20of=20blocking=20?= =?UTF-8?q?SW=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cache.addAll() rejects if any URL returns non-2xx (e.g. 401 from auth middleware). This blocked the entire SW installation, preventing PWA installability. Switch to individual fetch+put with graceful skip on failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/public/sw.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/web/public/sw.js b/src/web/public/sw.js index 35a65ac0..50c31850 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -52,7 +52,20 @@ const PRECACHE_URLS = [ self.addEventListener('install', (event) => { event.waitUntil( - caches.open(SHELL_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) + caches.open(SHELL_CACHE).then((cache) => + // Use individual fetch+put instead of addAll — addAll rejects if ANY request + // fails (e.g., 401 from auth middleware), which would block SW installation entirely. + Promise.all( + PRECACHE_URLS.map((url) => + fetch(url, { credentials: 'same-origin' }) + .then((res) => { + if (res.ok) return cache.put(url, res); + // Skip non-ok responses (auth failures, etc.) — they'll be fetched from network later + }) + .catch(() => {}) // Network error — skip, don't block install + ) + ) + ) ); }); From b93b500dc160adfc7fae608a0b22fb1b27e0fbd8 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 28 Apr 2026 09:35:06 +0000 Subject: [PATCH 16/23] =?UTF-8?q?fix(pwa):=20bypass=20auth=20for=20manifes?= =?UTF-8?q?t=20and=20icons=20=E2=80=94=20required=20for=20installability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers fetch manifest.json and /icons/* without credentials during PWA installability checks and "Add to Home Screen". These must be publicly accessible or Chrome won't detect the manifest at all. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/middleware/auth.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 848f03aa..2cdb7704 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -88,6 +88,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au return; } + // PWA assets must be publicly accessible — browsers fetch manifest and icons + // without credentials during installability checks and "Add to Home Screen" + if (req.url === '/manifest.json' || req.url?.startsWith('/icons/')) { + done(); + return; + } + const clientIp = req.ip; // Rate limit: reject if too many failed attempts from this IP From 582cd61e6ff64e0201d3297e7abb073b49fedf04 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 28 Apr 2026 09:50:58 +0000 Subject: [PATCH 17/23] docs: update CLAUDE.md with dev/prod instance workflow Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ffe223a8..50741ccc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,17 @@ npm run format # Prettier write ``` ### Running a dev instance + +Codeman production runs on port 3000 via systemd (`codeman.service`) — it hosts every Claude Code session on this server, **including this one**. Never restart production to test dev changes. + +The dev instance runs on port 3001 from the `codeman-dev` checkout: ```bash -node dist/index.js web --port 3001 # Run on non-default port +node dist/index.js web --port 3001 # Dev instance — safe to restart ``` +**Deploy to dev:** `npm run build && rsync -a --delete dist/ ~/.codeman/app/dist/` then restart the dev process (not systemd). +**Deploy to prod:** Only after testing on dev. `sudo systemctl restart codeman` picks up the new dist. + ## Architecture ``` @@ -85,9 +92,21 @@ src/ - PRs go from fork branches → upstream `master` - Use `gh` CLI for GitHub operations (authenticated via `gh auth`) +### After pushing a PR +1. Merge the fix branch into local `master`: `git merge fix/ --no-edit` +2. Rebuild: `npm run build` +3. Test on dev (port 3001) first — never deploy untested changes to prod +4. Update production: `rsync -a --delete dist/ ~/.codeman/app/dist/` +5. CSS/JS changes take effect on browser hard-refresh +6. Backend changes (orchestrator, server, etc.) need: `sudo systemctl restart codeman` + - **Never** `kill` + `nohup` the production process — it's managed by systemd + - **Never** restart prod to test — use the dev instance on port 3001 + ## Gotchas - The upstream default branch is `master`, not `main` - Build output goes to `dist/` — always rebuild after source changes - CSS changes require `npm run build` then browser hard-refresh - For quick CSS hotfixes to production: copy `dist/web/public/.css` directly -- The `CLAUDE.md` in this repo is also used by Codeman's worktree task runner — don't remove the worktree instructions if they exist +- Orchestrator dispatch worktrees go to `~/.codeman/dispatch-worktrees/`, not `~/codeman-cases/` +- `sendInput()` on a Session calls `runPrompt()` which spawns a NEW process — for interactive sessions use `writeViaMux()` instead +- Local `master` may be ahead of `origin/master` with unmerged PR fixes — this is intentional From 82c9e5cb84690f8748554cc0358863f2d9c7b35e Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Wed, 29 Apr 2026 09:34:31 +0000 Subject: [PATCH 18/23] fix(pwa): wire up push notification actions to permission responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission requests from push notifications were ignored — the SW message listener extracted only sessionId and discarded the action field. Also, when switching to a session with a pending permission hook, the inline block was never re-rendered. - Extract `action` from SW notification-click messages and map approve→'y', deny→'n' to sendPermissionResponse - Store hook data in _pendingPermissionData for deferred rendering - Re-render permission block on selectSession if one is pending - Handle notification body click (no action) to show the modal - Clean up _pendingPermissionData on response, reset, and session delete Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/public/app.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 2a0c43aa..7582119f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4921,6 +4921,10 @@ class CodemanApp { // Elicitation quick-reply state: { sessionId, question, options: [{val,label}] } | null this.pendingElicitation = null; + // Deferred permission block data — stored when a permission hook fires for a non-active + // session so the block can be rendered when the user later switches to that session. + this._pendingPermissionData = null; + // AskUserQuestion is now handled entirely by the inline tv-auq-block in TranscriptView. // No panel state needed — the block element manages its own lifecycle. @@ -7425,6 +7429,8 @@ class CodemanApp { title: 'Permission Required', message: toolInfo || 'Claude needs tool approval to continue', }); + // Store for deferred rendering when user switches to this session later (PWA background etc.) + this._pendingPermissionData = { sessionId: data.sessionId, data }; // Show inline permission block in transcript view if (data.sessionId === this.activeSessionId && TranscriptView._sessionId === data.sessionId) { TranscriptView.setWorking(false); @@ -7586,6 +7592,7 @@ class CodemanApp { /** Sends a permission prompt response (y/n/a) and clears the inline block. */ sendPermissionResponse(sessionId, key) { + this._pendingPermissionData = null; this.clearPendingHooks(sessionId, 'permission_prompt'); TranscriptView._removePermissionBlock(); fetch(`/api/sessions/${sessionId}/input`, { @@ -8512,6 +8519,7 @@ class CodemanApp { this._loadBufferQueue = null; // Clear pending hooks this.pendingHooks.clear(); + this._pendingPermissionData = null; // Clear parent name cache (prevents stale session name entries accumulating) if (this._parentNameCache) this._parentNameCache.clear(); // Clear subagent activity/results maps (prevents leaks if data.subagents is missing) @@ -9488,6 +9496,12 @@ class CodemanApp { } // Clear idle hooks on view, but keep action hooks until user interacts this.clearPendingHooks(sessionId, 'idle_prompt'); + // Deferred permission block: if a permission hook fired while this session wasn't active, + // render the inline block now that the user has switched to it. + if (this._pendingPermissionData?.sessionId === sessionId && TranscriptView._sessionId === sessionId) { + TranscriptView.setWorking(false); + TranscriptView._renderPermissionBlock(this._pendingPermissionData.data); + } // Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status this._updateActiveTabImmediate(sessionId); this.renderSessionTabs(); @@ -9769,6 +9783,7 @@ class CodemanApp { this.ralphClosedSessions.delete(sessionId); this.projectInsights.delete(sessionId); this.pendingHooks.delete(sessionId); + if (this._pendingPermissionData?.sessionId === sessionId) this._pendingPermissionData = null; this.tabAlerts.delete(sessionId); this.removeAttentionItemsForSession(sessionId); this.clearCountdownTimers(sessionId); @@ -13282,8 +13297,21 @@ class CodemanApp { // Listen for messages from service worker (notification clicks) navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'notification-click') { - const { sessionId } = event.data; + const { sessionId, action } = event.data; if (sessionId && this.sessions.has(sessionId)) { + // Map push notification actions to permission responses + if (action === 'approve') { + this.sendPermissionResponse(sessionId, 'y'); + } else if (action === 'deny') { + this.sendPermissionResponse(sessionId, 'n'); + } else if (!action && this._pendingPermissionData?.sessionId === sessionId) { + // Notification body clicked (no action button) — show the permission block. + // selectSession early-returns if session is already active, so render here. + if (TranscriptView._sessionId === sessionId) { + TranscriptView.setWorking(false); + TranscriptView._renderPermissionBlock(this._pendingPermissionData.data); + } + } this.selectSession(sessionId); } window.focus(); From 74609097e69eea9cb2f30e99514dd6ae9bbd0c4e Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Wed, 29 Apr 2026 10:02:29 +0000 Subject: [PATCH 19/23] fix(hooks): write hooks config for quick sessions created via POST /api/sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions created via the "+" button in the sidebar use POST /api/sessions with a workingDir but no casePath. This route never called writeHooksConfig(), so permission_prompt and elicitation hooks were missing — notifications never fired for these sessions. Now writes hooks config to {workingDir}/.claude/settings.local.json for Claude-mode sessions, matching the quick-start flow behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/routes/session-routes.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9e071f70..81d54c2b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -136,9 +136,15 @@ export function registerSessionRoutes( } } + // Ensure hooks config exists for Claude sessions so permission/elicitation + // hooks fire even for sessions created outside the quick-start/case flow. + const mode = body.mode || 'claude'; + if (mode === 'claude') { + await writeHooksConfig(workingDir); + } + const globalNice = await ctx.getGlobalNiceConfig(); const modelConfig = await ctx.getModelConfig(); - const mode = body.mode || 'claude'; const model = mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); From 8cabcdc5b8943e248234c21ea4dd57426a59f6c8 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 5 May 2026 06:28:10 +0000 Subject: [PATCH 20/23] fix(auth): only count rate-limit failures when credentials are provided Expired session cookies (no Authorization header) no longer increment the per-IP failure counter. This prevents the 2s system-stats poller from triggering a 15-minute IP ban when the session expires. Co-Authored-By: Claude Opus 4.6 --- src/web/middleware/auth.ts | 8 +++++-- test/auth-security.test.ts | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 2cdb7704..e8edf92f 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -147,8 +147,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au return; } - // Auth failed — track failure count - authFailures.set(clientIp, failures + 1); + // Auth failed — only count toward rate limit when credentials were actually + // provided (brute-force attempt). Expired-session requests (no Authorization + // header) should not trigger rate limiting. + if (auth) { + authFailures.set(clientIp, failures + 1); + } reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); reply.code(401).send('Unauthorized'); diff --git a/test/auth-security.test.ts b/test/auth-security.test.ts index 14ae8528..a298afed 100644 --- a/test/auth-security.test.ts +++ b/test/auth-security.test.ts @@ -317,3 +317,51 @@ describe('No-Auth Server Warning', () => { expect(res.status).toBe(200); }); }); + +describe('Rate Limiting — credential-less requests', () => { + let rlServer: WebServer; + let rlBaseUrl: string; + const RL_PORT = 3162; + + beforeAll(async () => { + process.env.CODEMAN_PASSWORD = TEST_PASS; + process.env.CODEMAN_USERNAME = TEST_USER; + rlServer = new WebServer(RL_PORT, false, true); + await rlServer.start(); + rlBaseUrl = `http://localhost:${RL_PORT}`; + }); + + afterAll(async () => { + await rlServer.stop(); + }); + + it('should NOT count requests without credentials toward rate limit', async () => { + // Send 15 requests with no credentials (expired session scenario) + for (let i = 0; i < 15; i++) { + const res = await fetch(`${rlBaseUrl}/api/status`); + // Should always be 401, never 429 + expect(res.status).toBe(401); + } + + // After 15 credential-less failures, valid credentials should still work + const res = await fetch(`${rlBaseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + expect(res.status).toBe(200); + }); + + it('should still count requests WITH wrong credentials toward rate limit', async () => { + // Send 10 requests with wrong credentials + for (let i = 0; i < 10; i++) { + await fetch(`${rlBaseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-' + i) }, + }); + } + + // 11th attempt should be rate-limited + const res = await fetch(`${rlBaseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') }, + }); + expect(res.status).toBe(429); + }); +}); From 8e9daae4376e2dc34d23249d84dee37d2f80c1b2 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 5 May 2026 06:31:48 +0000 Subject: [PATCH 21/23] fix(auth): use strict undefined check for auth header presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty Authorization header (`''`) is falsy but represents a deliberate probing attempt — it should still count toward rate limiting. Only truly absent headers (undefined) should be excluded. Also adds a clarifying comment to the test explaining state dependency between sequential rate-limit tests. Co-Authored-By: Claude Opus 4.6 --- src/web/middleware/auth.ts | 2 +- test/auth-security.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index e8edf92f..65ed8e9c 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -150,7 +150,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au // Auth failed — only count toward rate limit when credentials were actually // provided (brute-force attempt). Expired-session requests (no Authorization // header) should not trigger rate limiting. - if (auth) { + if (auth !== undefined) { authFailures.set(clientIp, failures + 1); } diff --git a/test/auth-security.test.ts b/test/auth-security.test.ts index a298afed..da7f500a 100644 --- a/test/auth-security.test.ts +++ b/test/auth-security.test.ts @@ -351,6 +351,9 @@ describe('Rate Limiting — credential-less requests', () => { }); it('should still count requests WITH wrong credentials toward rate limit', async () => { + // The previous test ends with a successful auth that resets the failure + // counter for this IP (authFailures.delete). This test starts from 0. + // Send 10 requests with wrong credentials for (let i = 0; i < 10; i++) { await fetch(`${rlBaseUrl}/api/status`, { From c91182d2e104204e26635ea2b1522d1e301e63f9 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 5 May 2026 07:19:36 +0000 Subject: [PATCH 22/23] =?UTF-8?q?fix(pwa):=20stop=20request=20storm=20on?= =?UTF-8?q?=20auth=20expiry=20=E2=80=94=20circuit=20breaker=20+=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a 401 is detected, _api() sets authExpired flag and short-circuits all subsequent calls. _onAuthExpired() stops SSE, system stats (2s), transcript sync (30s), and ActionDashboard (30s) pollers. A full-screen overlay prompts the user to re-authenticate via page reload. Co-Authored-By: Claude Opus 4.6 --- src/web/public/api-client.js | 7 +++++ src/web/public/app.js | 46 +++++++++++++++++++++++++++++++++ src/web/public/styles.css | 50 ++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/src/web/public/api-client.js b/src/web/public/api-client.js index 092a0810..4bb0eb3b 100644 --- a/src/web/public/api-client.js +++ b/src/web/public/api-client.js @@ -22,6 +22,9 @@ Object.assign(CodemanApp.prototype, { */ async _api(path, opts = {}) { const { method = 'GET', body, signal } = opts; + // Short-circuit all API calls while auth is expired — no point + // hitting the server when we know the session is gone. + if (this.authExpired) return null; const fetchOpts = { method, signal }; if (body !== undefined) { fetchOpts.headers = { 'Content-Type': 'application/json' }; @@ -29,6 +32,10 @@ Object.assign(CodemanApp.prototype, { } try { const res = await fetch(path, fetchOpts); + if (res.status === 401 && !this.authExpired) { + this.authExpired = true; + this._onAuthExpired(); + } return res; } catch { return null; diff --git a/src/web/public/app.js b/src/web/public/app.js index 7582119f..767b72ef 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3102,6 +3102,7 @@ const TranscriptView = { // Periodic sync: catches missed SSE blocks by comparing against the backend transcript. // Runs every 30s. Only active when transcript view is visible and not mid-load. _periodicSync() { + if (window.app?.authExpired) return; if (!this._sessionId || !this._container) return; const sessionId = this._sessionId; const state = this._getState(sessionId); @@ -6575,6 +6576,51 @@ class CodemanApp { } } + /** + * Called when a 401 response is detected — session cookie has expired. + * Stops all pollers to prevent request storms and shows a re-auth overlay. + */ + _onAuthExpired() { + // Stop SSE connection and prevent reconnect + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + if (this.sseReconnectTimeout) { + clearTimeout(this.sseReconnectTimeout); + this.sseReconnectTimeout = null; + } + + // Stop system stats polling (2s interval) + this.stopSystemStatsPolling(); + + // Stop ActionDashboard polling (30s interval) + if (typeof ActionDashboard !== 'undefined') ActionDashboard.stopPolling(); + + this.setConnectionStatus('disconnected'); + + // Show session-expired overlay + this._showAuthExpiredOverlay(); + } + + _showAuthExpiredOverlay() { + // Prevent duplicate overlays + if (document.getElementById('authExpiredOverlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'authExpiredOverlay'; + overlay.className = 'auth-expired-overlay'; + overlay.innerHTML = ` +
+
🔒
+

Session Expired

+

Your authentication session has timed out.

+ +
+ `; + document.body.appendChild(overlay); + } + // ═══════════════════════════════════════════════════════════════ // SSE Event Handlers // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b4a1e67d..6ef90bde 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12799,3 +12799,53 @@ kbd { border-radius: 4px; } +/* Auth expired overlay — shown when session cookie times out */ +.auth-expired-overlay { + position: fixed; + inset: 0; + z-index: 100000; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; +} + +.auth-expired-content { + text-align: center; + color: var(--text-primary); + max-width: 320px; + padding: 32px; +} + +.auth-expired-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.auth-expired-content h2 { + margin: 0 0 8px; + font-size: 20px; +} + +.auth-expired-content p { + margin: 0 0 24px; + color: var(--text-secondary); + font-size: 14px; +} + +.auth-expired-btn { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 10px 24px; + font-size: 14px; + cursor: pointer; + font-weight: 500; +} + +.auth-expired-btn:hover { + filter: brightness(1.1); +} + From 8624183de551bd704d922335e9f22ded0ef33c36 Mon Sep 17 00:00:00 2001 From: Maurizio Petrone Date: Tue, 5 May 2026 07:24:23 +0000 Subject: [PATCH 23/23] fix(pwa): guard all reconnect and polling paths against authExpired Co-Authored-By: Claude Opus 4.6 --- src/web/public/app.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 767b72ef..75977a2e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -6473,6 +6473,9 @@ class CodemanApp { return; } + // Don't reconnect if auth has expired + if (this.authExpired) return; + // Clear any pending reconnect timeout to prevent duplicate connections if (this.sseReconnectTimeout) { clearTimeout(this.sseReconnectTimeout); @@ -6543,6 +6546,8 @@ class CodemanApp { if (this.sseReconnectTimeout) { clearTimeout(this.sseReconnectTimeout); } + // Don't schedule reconnect if auth has expired — circuit breaker handles it + if (this.authExpired) return; // Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s // Fast first retry (200ms) for server-restart case (COM deploy), // then ramp up for real network issues. @@ -6615,10 +6620,11 @@ class CodemanApp {
🔒

Session Expired

Your authentication session has timed out.

- + `; document.body.appendChild(overlay); + overlay.querySelector('.auth-expired-btn').addEventListener('click', () => location.reload()); } // ═══════════════════════════════════════════════════════════════ @@ -8448,6 +8454,7 @@ class CodemanApp { setupOnlineDetection() { window.addEventListener('online', () => { + if (this.authExpired) return; this.isOnline = true; this.reconnectAttempts = 0; this.connectSSE(); @@ -8469,6 +8476,7 @@ class CodemanApp { * Fixes the "frozen tab" bug where terminal stops updating after switching away. */ _onTabVisible() { + if (this.authExpired) return; if (!this.isOnline) return; const es = this.eventSource; if (!es || es.readyState === EventSource.CLOSED) { @@ -20830,6 +20838,7 @@ class CodemanApp { } async fetchSystemStats() { + if (this.authExpired) return; // Skip polling when system stats display is hidden const statsEl = document.getElementById('headerSystemStats'); if (!statsEl || statsEl.style.display === 'none') return; @@ -24351,6 +24360,7 @@ const ActionDashboard = { }, async refresh() { + if (window.app?.authExpired) return; // Fetch all three data sources in parallel const [sessRes, wiRes, wtRes] = await Promise.allSettled([ fetch('/api/sessions').then(r => { if (typeof app !== 'undefined') app._checkStaleSessionData(r); return r.ok ? r.json() : []; }),