mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb9149879d | ||
|
|
cdbde9f36f | ||
|
|
f07905b193 | ||
|
|
05c94f5ac0 | ||
|
|
aaf22909bc | ||
|
|
94908ffdb5 | ||
|
|
82fe3cf684 | ||
|
|
6946ca0b8a | ||
|
|
ea4b940cef | ||
|
|
c6f428e687 | ||
|
|
c8f3981b0c | ||
|
|
499d35566b | ||
|
|
da999b130e | ||
|
|
cbc54fc98d | ||
|
|
4e2c1b9989 | ||
|
|
1c94995290 | ||
|
|
f485085174 | ||
|
|
19aabe34d2 | ||
|
|
98fa8c00d1 |
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
Add an optional collapsible left session sidebar as an alternative to the header tab strip.
|
||||
|
||||
With many concurrent sessions the horizontal strip wraps into several rows and stops being scannable. The new layout puts the session list in a vertical `<aside>` with a filter box and a live session count, collapsible to a 44px rail that keeps the status dots and task badges visible.
|
||||
|
||||
Opt-in via Settings → Layout → Tabs → Session List Layout; the default stays the header strip, so nothing changes unless you switch. Both layouts share one `#sessionTabs` element that is re-parented between mount points, so every existing affordance (status, mode badge, alerts, drag-reorder, keyboard navigation, web tabs, subagent windows) behaves identically in both. Below 1024px the sidebar is an off-canvas drawer that overlays the terminal instead of shrinking it. Collapse state persists per device; `Alt+B` toggles it.
|
||||
@@ -1,5 +1,26 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c01edcb: Add an optional collapsible left session sidebar as an alternative to the header tab strip.
|
||||
|
||||
With many concurrent sessions the horizontal strip wraps into several rows and stops being scannable. The new layout puts the session list in a vertical `<aside>` with a filter box and a live session count, collapsible to a 44px rail that keeps the status dots and task badges visible.
|
||||
|
||||
Opt-in via Settings → Layout → Tabs → Session List Layout; the default stays the header strip, so nothing changes unless you switch. Both layouts share one `#sessionTabs` element that is re-parented between mount points, so every existing affordance (status, mode badge, alerts, drag-reorder, keyboard navigation, web tabs, subagent windows) behaves identically in both. Below 1024px the sidebar is an off-canvas drawer that overlays the terminal instead of shrinking it. Collapse state persists per device; `Alt+B` toggles it.
|
||||
|
||||
- Codeman hooks now install into every claude workspace at session create, not just cases Codeman created (#304). Linked cases and cloned repos previously ran hook-blind: tab alerts, the Approvals Inbox, and the agent skill's stop/blocked wait signals were silently dead there. The install is an add-only merge that preserves user-authored hooks and leaves malformed files untouched, and a boot sweep heals sessions recovered from a restart. Opt out with the new synced `workspaceHooksEnabled` setting. Note: a `.claude/settings.local.json` can now appear in repos you link as cases; it contains no secrets. Remote SSH attaches and creates without a `workingDir` never write hooks.
|
||||
|
||||
File paths an agent prints are now clickable in both the terminal and the response viewer, opening the file preview overlay, including paths outside the session workspace (#306). Out-of-workspace paths are served through the attachment routes' extension allowlist, realpath confinement, and sensitive-path blocklist; Codeman's own credential-bearing files (`settings.json`, `push-keys.json`, `intents.json`, `state*.json`) are blocked from serving.
|
||||
|
||||
Both home screens (the desktop home tab rail and the phone overview) sort sessions by activity instead of tab order (#303): blocked sessions first with the longest-blocked on top, then running sessions longest-running first, then quiet sessions most recently active first. A turn starting now pushes a session state broadcast so the ordering stays live after page load.
|
||||
|
||||
The codeman agent skill docs teach hook presence as a setting to check rather than a consequence of who created the workspace, and the §0 preamble stamp is bumped to 1.19.0 (#305).
|
||||
|
||||
### Thanks
|
||||
- @christianhaberl designed and built the collapsible left session sidebar (#307)
|
||||
|
||||
## 1.18.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.18.4 (must match `package.json`)
|
||||
**Version**: 1.19.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in session-routes.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from both create paths and from `restoreMuxSessions()` for sessions recovered on server start. Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
|
||||
|
||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||
|
||||
@@ -230,6 +230,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND the response viewer's `_linkifyFilePaths()`; a fresh instance per call, since `lastIndex` is per-object state. The chat linkifier walks TEXT NODES with DOM APIs (the source is model output; never rebuild sanitized markup as a string) and skips subtrees already inside an `<a>`. ⚠️ **An out-of-workspace path is served through the ATTACHMENT routes, not the file routes** — `file-content`/`file-raw` are workspace-confined and 404 exactly the paths agents print most (a `/tmp` capture, Claude's scratchpad), so `openFilePreview()` registers such a path via `POST /api/sessions/:id/attachments` with **`notify: false`** (suppresses only the `attachment:detected` broadcast — same guard, same routes; without it every click also popped a card announcing the file already on screen) and renders by id. The click is an explicit action on the explicit, Origin-guarded route, which is what distinguishes it from the force-confined magic-link scanner. ⚠️ **Media extensions are single-sourced** (`VIDEO_ATTACHMENT_EXTENSIONS`/`AUDIO_ATTACHMENT_EXTENSIONS` in `attachment-registry.ts`, imported by `file-content`'s classification) so a clip plays the same in or out of the workspace; a player needs all THREE of allowlist + a real `MIME_TYPES` entry (octet-stream renders a dead player) + the range-aware body. ⚠️ **`TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS`** (never a second list): if the viewer would edit it inside the workspace, it can be read outside. Widening READ must never widen RUN, so `html`/`htm` joined `svg` in `serveRawFile`'s download-only branch, other text goes out as inert `text/plain`+`nosniff`, and `~/.codeman*/state.json` joined `isSensitivePath` (it persists `envOverrides`, which can hold `GEMINI_API_KEY`). ⚠️ The terminal sends an **out-of-workspace** path to the preview instead of the log viewer (that one spawns `tail -f` and reaches only workspace + `/var/log` + `~/logs`); in-workspace text keeps the tail viewer and `file-stream-manager`'s allowlist is untouched. The image-watcher keeps its own narrow detection list, so none of this cards every file an agent writes. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
@@ -262,7 +264,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **overview order** (see below), and each carries a **created** stamp plus the **state duration** the order is computed from (`created 3d ago · working 12m`, word and anchor from `_mobileOverviewSince()` so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now". ⚠️ The number badge is the **Alt+1..9 index**, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure + unit-tested in `test/session-overview-order.test.ts`): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is `needs` → `error` → `waiting` → `working` → `idle` → `done`, and ⚠️ **the tiebreak flips direction halfway down**: states a session is still IN sort **oldest-first** (blocked longest / running longest = most urgent), states it has STOPPED in sort **newest-first** (the session that just went quiet is the one you came back for). ⚠️ The running group keys off **`lastSubmitAt`** (the pane's last Enter), never `lastActivityAt`: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it. ⚠️ A **0 stamp means "unknown", not "the epoch"**, and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (`orderIndex`), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
@@ -296,7 +300,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), local echo overlay (7).
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), local echo overlay (7).
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
|
||||
@@ -122,6 +122,24 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
|
||||
|
||||
### File-path links (terminal + response viewer)
|
||||
|
||||
A file path an agent prints is a link on both surfaces it can appear on, and clicking it opens the file-preview overlay. Three things make that work and each has bitten:
|
||||
|
||||
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both build a fresh instance from it. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
|
||||
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. `test/response-viewer-file-links.test.ts` pins both properties.
|
||||
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow); nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
|
||||
**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
|
||||
|
||||
**Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes.
|
||||
|
||||
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to `/mnt/d`), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+16
-15
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.18.3$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.19.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -138,14 +138,14 @@ spawn_worker() {
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
||||
# quick-start RESOLVES the name before creating: a linked case or an existing dir
|
||||
# wins over a fresh scratch case, so "created => hooks" is only true after this one
|
||||
# local grep (the same marker the server itself checks for). No marker means sendwait
|
||||
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
|
||||
# rather than run the job there.
|
||||
# The server installs hooks into every claude workspace now, so this grep normally
|
||||
# passes; it stays because the install is gated on a setting the operator can turn
|
||||
# off, remote sessions never get hooks, and a session created by an older server
|
||||
# still has none. No marker means sendwait would false-resolve on flapping idle,
|
||||
# possibly inside the user's REAL repo: refuse rather than run the job there.
|
||||
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
||||
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
|
||||
echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2
|
||||
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
|
||||
# dialog can never pass the composer wait, so probing early keeps a cold case from
|
||||
@@ -233,10 +233,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.18.3
|
||||
CODEMAN_PREAMBLE=1.19.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -287,7 +287,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
'reply with one line: your model name') # tasks, same order as N
|
||||
@@ -343,7 +343,8 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
`~/codeman-cases/<name>`, not your repo. A name that already means something (a
|
||||
linked case, a pre-existing directory) is refused by `spawn_worker` rather than
|
||||
silently reused. Spawning where the work actually is (a linked case, a git worktree)
|
||||
is a different call with **no hooks**, and the costliest mistake in this skill: §5.1.
|
||||
is a different call, and picking the wrong one is the costliest mistake in this
|
||||
skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it.
|
||||
- `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter.
|
||||
A prompt without the `\r` is never submitted (§3), a reused `seq` is silently
|
||||
swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint
|
||||
@@ -358,9 +359,9 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
|
||||
| I want to | Call | Detail |
|
||||
|-----------|------|--------|
|
||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case: full signals there. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`, and expect **no hooks**. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
|
||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is only trustworthy in a **case Codeman created** (claude mode + hooks present). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -60,14 +60,14 @@ spawn_worker() {
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
||||
# quick-start RESOLVES the name before creating: a linked case or an existing dir
|
||||
# wins over a fresh scratch case, so "created => hooks" is only true after this one
|
||||
# local grep (the same marker the server itself checks for). No marker means sendwait
|
||||
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
|
||||
# rather than run the job there.
|
||||
# The server installs hooks into every claude workspace now, so this grep normally
|
||||
# passes; it stays because the install is gated on a setting the operator can turn
|
||||
# off, remote sessions never get hooks, and a session created by an older server
|
||||
# still has none. No marker means sendwait would false-resolve on flapping idle,
|
||||
# possibly inside the user's REAL repo: refuse rather than run the job there.
|
||||
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
||||
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
|
||||
echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2
|
||||
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
|
||||
delete_session "$sid" >/dev/null; return 1; }
|
||||
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
|
||||
# dialog can never pass the composer wait, so probing early keeps a cold case from
|
||||
@@ -155,4 +155,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.18.3
|
||||
CODEMAN_PREAMBLE=1.19.0
|
||||
|
||||
@@ -251,10 +251,12 @@ that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
|
||||
**It means** that session has no Codeman hooks, so `stop` can never fire and the wait
|
||||
silently degraded to `idle`, which flaps mid-turn. Nothing rejected your request:
|
||||
`wait:true` (and even an explicit `until=stop`) is accepted because the 400 is about
|
||||
session **mode**, and the mode really is `claude`. Hooks are written only when Codeman
|
||||
**creates** the directory; a linked case or a raw `workingDir` gets none (an existing
|
||||
case that Codeman created earlier keeps the block it was given), see the table under
|
||||
[Signals by mode](#signals-by-mode). Measured: on a
|
||||
session **mode**, and the mode really is `claude`. Hooks are installed into every
|
||||
claude workspace at session create (synced `workspaceHooksEnabled`, default ON) and
|
||||
swept across recovered sessions at boot, so a linked case or a raw `workingDir` gets
|
||||
them too; with the setting off, on a remote session, or on a session from an older
|
||||
server, they are absent, see the table under
|
||||
[Signals by mode](#signals-by-mode). Measured before that changed: on a
|
||||
linked case whose `.claude/settings.local.json` carries env/model/permissions/statusLine
|
||||
and no `hooks` block, a `wait?until=stop,exit` parked for twelve consecutive 60 s rounds
|
||||
never resolved although the worker finished its turn.
|
||||
@@ -360,10 +362,10 @@ loop.
|
||||
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
|
||||
to match a case the user linked in lands in that **real repo**, not a fresh scratch
|
||||
directory. Pick distinctive scratch names, and use a linked name deliberately when you
|
||||
do want a worker in an existing checkout. ⚠️ It also decides whether you get hooks:
|
||||
Codeman writes them only when it **creates** the directory, so a linked case or a raw
|
||||
path gives you a worker with no `stop` signal, while a scratch case Codeman created
|
||||
earlier keeps working signals ([Signals by mode](#signals-by-mode)).
|
||||
do want a worker in an existing checkout. It no longer decides whether you get hooks:
|
||||
every claude create path installs them, so a linked case and a raw path both get a
|
||||
`stop` signal unless the operator turned `workspaceHooksEnabled` off
|
||||
([Signals by mode](#signals-by-mode)).
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
@@ -614,35 +616,27 @@ Three bounded long-polls. Shared semantics:
|
||||
| `exit` | PTY exited or session deleted | every mode |
|
||||
|
||||
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
|
||||
precondition is that the session's working directory has a Codeman hooks block**, and
|
||||
whether it does depends on who created the directory:
|
||||
precondition is that the session's working directory has a Codeman hooks block**, which
|
||||
is now installed by default rather than depending on who created the directory:
|
||||
|
||||
| The worker's directory | Hooks | `stop` / `blocked` | Synchronize with |
|
||||
|------------------------|-------|--------------------|------------------|
|
||||
| Codeman created it (`quick-start` with a NEW `caseName`, `POST /api/cases`, clone, docker quickcreate) | written at create | fire | send-and-wait on `stop` |
|
||||
| Codeman never created it (a linked case pointing at your own checkout, a raw `workingDir`) | none written | never fire | `wait-output` markers only |
|
||||
| any claude workspace, with `workspaceHooksEnabled` ON (the default) | installed at session create, add-only merge | fire | send-and-wait on `stop` |
|
||||
| the same, with the setting OFF and no block already on disk | none added | never fire | `wait-output` markers only |
|
||||
| a remote SSH session, a docker case that opted out, a workspace Codeman cannot write | none | never fire | `wait-output` markers only |
|
||||
| a session created by a pre-1.19.0 server and never restarted since | whatever it had | only if present | check, then choose |
|
||||
|
||||
⚠️ **Docker cases are the one exception.** For a docker case, quick-start writes hooks
|
||||
whenever `.claude/settings.local.json` is *missing* (`session-routes.ts:2836-2845`:
|
||||
absent means write, present means refresh), regardless of who created that host
|
||||
directory. There the discriminator really is "does the settings file exist". No
|
||||
downstream advice changes, since docker quickcreate is already on the create side.
|
||||
The install is an add-only merge, so a user's own hook entries survive and a malformed
|
||||
settings file is left untouched. Sessions recovered at server boot get the same sweep,
|
||||
which is what heals sessions created before this behavior existed. When in doubt, test
|
||||
it rather than reason about it: grep for `/api/hook-event` in
|
||||
`<casePath>/.claude/settings.local.json`.
|
||||
|
||||
⚠️ For every non-docker case the discriminator is **who created the directory, not
|
||||
whether it exists now**. A
|
||||
scratch case Codeman created last week still has its hooks block on disk, so
|
||||
`quick-start` against that existing name gets working `stop` signals. Only a directory
|
||||
Codeman never created lacks them. When in doubt, test it rather than reason about it:
|
||||
grep for `/api/hook-event` in `<casePath>/.claude/settings.local.json`.
|
||||
|
||||
`writeHooksConfig()` runs only on the create paths (`case-routes.ts:341`, `:520`,
|
||||
`:869`, `ralph-routes.ts:318`, `session-routes.ts:2799` inside
|
||||
`if (!existsSync(resolvedCasePath))`, `:2841` for docker). Quick-start against a
|
||||
directory that already exists takes the else-if branch and calls
|
||||
`refreshStaleCodemanHooks()`, which returns immediately when there is no
|
||||
`settings.local.json` and again when the hooks it finds are not ours
|
||||
(`hooks-config.ts:706-731`); it never *adds* a hooks block. `POST /api/cases/link` is
|
||||
not on that list at all: it only records a name-to-path entry. See
|
||||
Before 1.19.0, `writeHooksConfig()` ran only on the create paths and `quick-start`
|
||||
against an existing directory called `refreshStaleCodemanHooks()`, which never *adds* a
|
||||
block, so a linked case or a raw `workingDir` had no hooks at all. `POST
|
||||
/api/cases/link` still only records a name-to-path entry; what changed is that the
|
||||
session-create path installs hooks regardless of how the directory got there. See
|
||||
[symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns).
|
||||
|
||||
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
|
||||
|
||||
@@ -196,12 +196,14 @@ idle:
|
||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||
Every topology in the next section is this protocol plus a wiring diagram.
|
||||
|
||||
1. **Spawn with a name, and with hooks.** Use `quick-start` with `sessionName` (the
|
||||
`--name` gate above), and let it **CREATE** the case. ⚠️ Linking does NOT install
|
||||
hooks (`POST /api/cases/link` writes only the name-to-path entry), and neither does a
|
||||
bare `POST /api/sessions`; a worker in a directory Codeman did not create has no
|
||||
`stop`/`blocked` signals at all and every synchronization below degrades to output
|
||||
markers. The discriminator is who created the directory, not whether it exists now.
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
||||
`--name` gate above). Session create installs the hooks block into the workspace
|
||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||
an older server may have none, and without them every synchronization below degrades
|
||||
to output markers. Grep `<casePath>/.claude/settings.local.json` for
|
||||
`/api/hook-event` at spawn rather than inferring it from how the directory got there.
|
||||
2. **Readiness before addressing.** Flow 1's ladder per worker, then the availability
|
||||
probe. A worker that fails the probe is an HTTP worker for the rest of the run; that
|
||||
is a routing decision, not an error.
|
||||
|
||||
@@ -492,9 +492,9 @@ What breaks if you use send-and-wait anyway: `wait:true` is accepted (the 400 is
|
||||
*mode*, not about hooks, and these are claude-mode sessions), so the call falls back to
|
||||
the default set's `idle`, which is a heuristic that flaps mid-turn. You get a "finished"
|
||||
answer for a turn still running, and `last-response` then hands you the *previous*
|
||||
turn's text. The contrast is the lesson: a worker in a case Codeman created (Flow 1) has
|
||||
the hooks, so `stop` there is definitive and free. In a worktree you pay one marker per
|
||||
worker instead.
|
||||
turn's text. The contrast is the lesson: a worker whose workspace carries the hooks
|
||||
block (Flow 1, and by default any other workspace too) has a `stop` that is definitive
|
||||
and free. Where the block is absent you pay one marker per worker instead.
|
||||
|
||||
```bash
|
||||
declare -A TOK
|
||||
|
||||
@@ -30,28 +30,40 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|
||||
| Where the work is | Call | Hooks, and therefore signals |
|
||||
|-------------------|------|------------------------------|
|
||||
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
|
||||
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **no hooks**, unless that repo already carries a Codeman hooks block from some earlier path. Check before relying on `stop` |
|
||||
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | **no hooks**: no `stop`, no `blocked`, synchronize with markers ([§5.5](#55-markers-for-hook-less-workers)) |
|
||||
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
|
||||
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
|
||||
|
||||
Read `.data.casePath` back from the `quick-start` response and check it is where you
|
||||
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
|
||||
the linked-cases registry **first**, so a name that collides with something the user
|
||||
linked in lands in that real repo rather than a scratch dir.
|
||||
|
||||
**The rule is who created the directory.** Codeman writes hooks only where it created
|
||||
the workspace itself: `quick-start` on a NEW case name, `POST /api/cases`, the repo
|
||||
clone, the docker quick-create. Those hooks persist, so a scratch case created last
|
||||
week still has them today. A directory that already existed when Codeman first pointed
|
||||
at it never gets them: `POST /api/cases/link` writes only the name-to-path entry in
|
||||
`linked-cases.json`, and quick-start into an existing path runs
|
||||
`refreshStaleCodemanHooks()`, which by design returns immediately when there is no
|
||||
Codeman hooks block to refresh. Source-verified by exhaustive call-site grep, and
|
||||
measured: a worker in a linked case never resolved a parked `wait?until=stop,exit`
|
||||
across twelve consecutive 60 s rounds, although it had finished its turn.
|
||||
**The rule is a setting, not who created the directory.** Every claude create path
|
||||
(`POST /api/sessions`, `POST /api/quick-start`, and quick-start's docker branch) now
|
||||
installs the hooks block into the workspace, and the server sweeps the workspaces of
|
||||
sessions it recovers at boot. So a linked case, a cloned repo and a hand-made git
|
||||
worktree all get `stop`/`blocked`, not just a scratch case Codeman scaffolded. The
|
||||
install is an **add-only merge**: a user's own hook entries and every other settings
|
||||
key survive, and a malformed settings file is left alone.
|
||||
|
||||
**Check, do not assume.** Read `<casePath>/.claude/settings.local.json` with your own
|
||||
file tools and look for `/api/hook-event`. Present means `stop`/`blocked` will fire;
|
||||
absent means they never will.
|
||||
The gate is the synced **`workspaceHooksEnabled`** setting, **default ON** (an absent
|
||||
key counts as ON). Turned OFF, the old behavior returns exactly: an existing Codeman
|
||||
block is still refreshed when stale, but one is never added, and the boot sweep is
|
||||
skipped. Three cases stay hook-less regardless: **remote SSH sessions** (their
|
||||
`workingDir` is a path on another host), **docker cases that opted out**, and any
|
||||
workspace Codeman cannot write to.
|
||||
|
||||
Until this landed, hooks existed only where Codeman created the directory, and the
|
||||
gap was invisible: a worker in a linked case never resolved a parked
|
||||
`wait?until=stop,exit` across twelve consecutive 60 s rounds, although it had finished
|
||||
its turn. If you are driving an older server, assume that older rule.
|
||||
|
||||
**Check, do not assume.** This is now the load-bearing habit, because you cannot tell
|
||||
from the call which way the setting is set, and an old session created before the fix
|
||||
on a server that has not restarted still has nothing. Read
|
||||
`<casePath>/.claude/settings.local.json` with your own file tools and look for
|
||||
`/api/hook-event`. Present means `stop`/`blocked` will fire; absent means they never
|
||||
will, whatever kind of workspace it is.
|
||||
|
||||
⚠️ **The hook-less failure is silent, and it is the worst one in this skill.**
|
||||
`"wait":true` is still **accepted** on a hook-less claude session: the 400 you may be
|
||||
@@ -59,9 +71,10 @@ expecting is about session *mode*, not about hooks. With no `stop` to resolve on
|
||||
default signal set falls back to the heuristic `idle`, which flaps mid-turn, so
|
||||
send-and-wait returns "finished" while the worker is still working, and the
|
||||
`last-response` you read next hands you the **previous** turn's text. No error is
|
||||
raised anywhere. In any workspace Codeman did not create, use markers
|
||||
([§5.5](#55-markers-for-hook-less-workers)) and treat send-and-wait's answer as
|
||||
unreliable.
|
||||
raised anywhere. Hooks are installed by default now, so this is rarer than it was, but
|
||||
the failure is unchanged when it happens: in any workspace whose settings file has no
|
||||
`/api/hook-event`, use markers ([§5.5](#55-markers-for-hook-less-workers)) and treat
|
||||
send-and-wait's answer as unreliable.
|
||||
|
||||
Spawning at a raw path:
|
||||
|
||||
@@ -238,11 +251,13 @@ fi
|
||||
|
||||
### 5.3 Send a task and wait
|
||||
|
||||
⚠️ **Precondition: this is the call to prefer only for a claude worker in a workspace
|
||||
Codeman created**, because it is trustworthy only when the `stop` hook exists. On a
|
||||
linked case or a raw path it is accepted, resolves on flapping `idle`, and reports a
|
||||
turn as finished while it is still running, with no error anywhere. Check hooks first
|
||||
([§5.1](#51-where-to-spawn)); where they are absent, use markers
|
||||
⚠️ **Precondition: a claude worker whose workspace has the hooks block**, because
|
||||
this is trustworthy only when the `stop` hook exists. Every claude create path installs
|
||||
it by default now, so that is the normal case, but where it is absent (the setting off,
|
||||
a remote session, an older server) the call is still accepted, resolves on flapping
|
||||
`idle`, and reports a turn as finished while it is still running, with no error
|
||||
anywhere. Check hooks first ([§5.1](#51-where-to-spawn)); where they are absent, use
|
||||
markers
|
||||
([§5.5](#55-markers-for-hook-less-workers)).
|
||||
|
||||
It registers the waiter *before* typing,
|
||||
|
||||
@@ -11,9 +11,46 @@ import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||
|
||||
/**
|
||||
* Playable media extensions, single-sourced here because the WORKSPACE preview
|
||||
* (`file-content`'s media classification) and the out-of-workspace attachment
|
||||
* path must agree on what plays. They diverged once: a video an agent wrote
|
||||
* inside the workspace played with a working scrub bar, while the same file in
|
||||
* `/tmp` was refused as an unsupported type, which reads as a bug rather than a
|
||||
* boundary. Serving is range-aware in both, which is what makes seeking work.
|
||||
*/
|
||||
export const VIDEO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||
export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'mp3',
|
||||
'wav',
|
||||
'ogg',
|
||||
'oga',
|
||||
'm4a',
|
||||
'aac',
|
||||
'flac',
|
||||
'opus',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Plain-text extensions, REUSING the File Viewer's edit-mode allowlist rather
|
||||
* than curating a second list that would drift from it. The rule reads: if the
|
||||
* viewer would open that file for editing inside the workspace, the same file
|
||||
* outside it can be read here. `svg` and `env` are absent from that list by
|
||||
* design and stay absent here.
|
||||
*
|
||||
* Why widen at all: the agent in the session can already `cat` any of these,
|
||||
* and every path-shaped surface (the picker, the workspace viewer) can already
|
||||
* show them. Refusing a `.log` an agent just wrote to `/tmp` bought no
|
||||
* confidentiality, it only made the click fail. The confidentiality gate is the
|
||||
* path guard that still runs on every registration (sensitive-file blocklist,
|
||||
* `/root` and `/etc` trees, realpath before the check), not the file's suffix.
|
||||
*/
|
||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
@@ -25,6 +62,9 @@ const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'pptx',
|
||||
'md',
|
||||
'txt',
|
||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
...AUDIO_ATTACHMENT_EXTENSIONS,
|
||||
...TEXT_ATTACHMENT_EXTENSIONS,
|
||||
]);
|
||||
|
||||
export type AttachmentSource = 'detected' | 'external';
|
||||
@@ -108,10 +148,14 @@ export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
const normalized = extension.toLowerCase().replace(/^\./, '');
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(normalized)) return 'image';
|
||||
if (VIDEO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'video';
|
||||
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
||||
if (normalized === 'pdf') return 'pdf';
|
||||
if (normalized === 'pptx') return 'presentation';
|
||||
if (normalized === 'md') return 'markdown';
|
||||
if (normalized === 'txt') return 'text';
|
||||
// Everything else in the text family reads as text, including code and
|
||||
// config: the card and the preview both treat it as a plain-text file.
|
||||
if (normalized === 'txt' || TEXT_ATTACHMENT_EXTENSIONS.has(normalized)) return 'text';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
|
||||
+20
-14
@@ -646,22 +646,28 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an explicitly managed case has the current Codeman hooks.
|
||||
* Ensures a workspace Codeman is about to run Claude in has the current Codeman hooks.
|
||||
*
|
||||
* Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid
|
||||
* user-owned settings file. It is therefore reserved for case quick-starts,
|
||||
* where the user has explicitly asked Codeman to manage that workspace. A
|
||||
* malformed existing file is left untouched rather than replaced.
|
||||
* Unlike `refreshStaleCodemanHooks`, this may ADD Codeman handlers to a settings
|
||||
* file that has none (a linked case, a cloned repo, any directory Codeman did not
|
||||
* scaffold). It merges rather than replaces, so a user's own hook entries survive,
|
||||
* and a malformed existing file is left untouched rather than replaced.
|
||||
*
|
||||
* ⚠️ It has NO production call site: PR #233 landed it with the hook scripts and never
|
||||
* wired it up, and knip can't flag it (`test/**` are entry points, so its tests count as
|
||||
* a use). Kept anyway, because it is redundant with neither sibling: `writeHooksConfig`
|
||||
* REPLACES a malformed settings file and rewrites unconditionally, and
|
||||
* `refreshStaleCodemanHooks` deliberately never adds hooks to a case that has none. The
|
||||
* one place it fits is quick-start's existing-case branch in session-routes.ts, and
|
||||
* moving that branch onto this function is a POLICY change (hooks would come back for a
|
||||
* user who deleted them from their case, and linked cases would start getting a hooks
|
||||
* block they have never had), so that call is left to the owner rather than made here.
|
||||
* ⚠️ That "may add" is a deliberate POLICY, adopted 2026-08-15 after the symptom it
|
||||
* causes was reported: hooks were only ever written when Codeman CREATED a case
|
||||
* directory, so every session in a linked case ran with no hooks at all and each
|
||||
* hook-driven surface was silently dead there — an AskUserQuestion dialog blocking
|
||||
* the pane while the tab and the phone overview both read a calm `idle`, no
|
||||
* Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn, and
|
||||
* no `stop`/`blocked` for the agent wait endpoints. The cost of the policy is the
|
||||
* other direction: a user who DELETES Codeman's hooks from a workspace gets them
|
||||
* back on the next session create there, because nothing on disk distinguishes
|
||||
* "removed on purpose" from "never had any".
|
||||
*
|
||||
* Called from both session-create paths (`POST /api/sessions`, `POST /api/quick-start`)
|
||||
* for claude mode, and from `restoreMuxSessions()` so sessions that predate this heal
|
||||
* on the next server start. Claude Code re-reads the file, so a session ALREADY running
|
||||
* in the workspace picks the hooks up without a restart (verified live, 2026-08-15).
|
||||
*/
|
||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||
|
||||
+51
-10
@@ -135,6 +135,14 @@ const MUX_STARTUP_DELAY_MS = 300;
|
||||
/** Delay before declaring session idle after last output (2 seconds) */
|
||||
const IDLE_DETECTION_DELAY_MS = 2000;
|
||||
|
||||
// How long after construction a RECOVERED session's wire activity stamp keeps
|
||||
// its restored previous-run value. Recovery attaches every pane at boot and the
|
||||
// attach repaint arrives as ordinary PTY output; without this window that
|
||||
// repaint would overwrite every restored stamp within the same second, which is
|
||||
// exactly the restart flattening the restore exists to prevent. Real actions
|
||||
// (input, task assignment, respawn) always stamp through it.
|
||||
const WIRE_ACTIVITY_SETTLE_MS = 15_000;
|
||||
|
||||
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||
|
||||
/** Graceful shutdown delay when stopping session (100ms) */
|
||||
@@ -392,6 +400,12 @@ export class Session extends EventEmitter {
|
||||
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
||||
private _errorBuffer: string = '';
|
||||
private _lastActivityAt: number;
|
||||
// Display twin of _lastActivityAt, reported by toState()/the getter. It can
|
||||
// lag behind on recovery: the restored previous-run stamp survives the attach
|
||||
// repaint (see _markActivity), so a restart does not flatten the home
|
||||
// screens' quiet ordering. Idle detection never reads it.
|
||||
private _wireActivityAt: number;
|
||||
private _wireActivitySettleUntil: number;
|
||||
private _claudeSessionId: string | null = null;
|
||||
private _totalCost: number = 0;
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
@@ -592,6 +606,8 @@ export class Session extends EventEmitter {
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
||||
lastSubmitAt?: number;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
lastActivityAt?: number;
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
||||
@@ -620,9 +636,18 @@ export class Session extends EventEmitter {
|
||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||
// days-old tmux session, and seeding last-activity from it would report a
|
||||
// freshly re-attached pane as having been silent for days, which the idle
|
||||
// confirmation reads as "already quiet" and the home screens print as its
|
||||
// idle duration. For a genuinely new session the two are the same instant.
|
||||
// confirmation reads as "already quiet". For a genuinely new session the
|
||||
// two are the same instant.
|
||||
this._lastActivityAt = Date.now();
|
||||
// The WIRE copy of the stamp is allowed to be older: recovery threads the
|
||||
// previous run's value so a restart does not flatten the home screens'
|
||||
// most-recently-quiet ordering (every stamp otherwise resets to boot time,
|
||||
// and the attach repaint re-bumps the rest within the same second). The
|
||||
// settle window in _markActivity() carries the restored value through that
|
||||
// repaint; the private stamp above stays boot-anchored because the idle
|
||||
// confirmation reads it as "how long has the pane been quiet".
|
||||
this._wireActivityAt = config.lastActivityAt || Date.now();
|
||||
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
@@ -794,7 +819,21 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
get lastActivityAt(): number {
|
||||
return this._lastActivityAt;
|
||||
return this._wireActivityAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp activity NOW. The private stamp (idle detection's "how long has the
|
||||
* pane been quiet") always moves; the wire stamp holds its restored value
|
||||
* through the post-recovery attach-repaint window unless the activity is a
|
||||
* real action (input, task assignment, respawn), which always writes through.
|
||||
*/
|
||||
private _markActivity(realAction = false): void {
|
||||
this._lastActivityAt = Date.now();
|
||||
if (realAction || Date.now() >= this._wireActivitySettleUntil) {
|
||||
this._wireActivityAt = this._lastActivityAt;
|
||||
this._wireActivitySettleUntil = 0;
|
||||
}
|
||||
}
|
||||
|
||||
get claudeSessionId(): string | null {
|
||||
@@ -1219,7 +1258,9 @@ export class Session extends EventEmitter {
|
||||
parentSessionId: this._parentSessionId,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
// The wire twin, not the private stamp: it survives the post-recovery
|
||||
// attach repaint, so the home screens' quiet ordering survives a restart.
|
||||
lastActivityAt: this._wireActivityAt,
|
||||
name: this._name,
|
||||
mode: this.mode,
|
||||
autoClearEnabled: this._autoOps.autoClearEnabled,
|
||||
@@ -1585,7 +1626,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
this._markActivity();
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
}
|
||||
@@ -2484,7 +2525,7 @@ export class Session extends EventEmitter {
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._altScreenSeqCarry = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._markActivity(true);
|
||||
}
|
||||
|
||||
private _clearAllTimers(): void {
|
||||
@@ -3083,7 +3124,7 @@ export class Session extends EventEmitter {
|
||||
// Legacy method for sending input - wraps runPrompt
|
||||
async sendInput(input: string): Promise<void> {
|
||||
this._status = 'busy';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._markActivity(true);
|
||||
this.runPrompt(input).catch((err) => {
|
||||
const errorMsg = getErrorMessage(err);
|
||||
// Clean up task state so the task queue doesn't get stuck
|
||||
@@ -3091,7 +3132,7 @@ export class Session extends EventEmitter {
|
||||
const taskId = this._currentTaskId;
|
||||
this._currentTaskId = null;
|
||||
this._status = 'idle';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._markActivity(true);
|
||||
this.emit('taskError', taskId, errorMsg);
|
||||
} else {
|
||||
this._status = 'idle';
|
||||
@@ -3252,13 +3293,13 @@ export class Session extends EventEmitter {
|
||||
this._textOutput.clear();
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lastActivityAt = Date.now();
|
||||
this._markActivity(true);
|
||||
}
|
||||
|
||||
clearTask(): void {
|
||||
this._currentTaskId = null;
|
||||
this._status = 'idle';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._markActivity(true);
|
||||
}
|
||||
|
||||
getOutput(): string {
|
||||
|
||||
+9
-1
@@ -63,7 +63,15 @@ export interface ImageDetectedEvent {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
|
||||
export type AttachmentDetectedType =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'pdf'
|
||||
| 'document'
|
||||
| 'presentation'
|
||||
| 'markdown'
|
||||
| 'text';
|
||||
|
||||
/**
|
||||
* Event emitted when a new previewable attachment file is detected in a session's
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface ConfigPort {
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||
getAgentSkillEnabled(): Promise<boolean>;
|
||||
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
|
||||
getWorkspaceHooksEnabled(): Promise<boolean>;
|
||||
/** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */
|
||||
getClaudeVoiceEnabled(): Promise<boolean>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
|
||||
+76
-4
@@ -1116,12 +1116,17 @@ class CodemanApp {
|
||||
if (digitMatch) {
|
||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
||||
// numbers actually painted on the tabs.
|
||||
if (idx < this.sessionOrder.length) {
|
||||
// numbers actually painted on the tabs. Resolve through the same
|
||||
// live-session projection the render paints: sessionOrder can
|
||||
// transiently hold a dead id (delete raced against the order sync),
|
||||
// and raw indexing then names the wrong tab for every key to its
|
||||
// right, web tabs included.
|
||||
const live = this.sessionOrder.filter((id) => this.sessions.has(id));
|
||||
if (idx < live.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
this.selectSession(live[idx]);
|
||||
} else {
|
||||
const webIdx = idx - this.sessionOrder.length;
|
||||
const webIdx = idx - live.length;
|
||||
const webId = (this.webviewOrder || [])[webIdx];
|
||||
if (webId) {
|
||||
e.preventDefault();
|
||||
@@ -2025,6 +2030,17 @@ class CodemanApp {
|
||||
if (!body || body.dataset.rvBound === '1') return;
|
||||
body.dataset.rvBound = '1';
|
||||
body.addEventListener('click', async (ev) => {
|
||||
// File path (_linkifyFilePaths): open it in the preview overlay, which
|
||||
// resolves workspace and out-of-workspace paths alike.
|
||||
const pathLink = ev.target.closest('a.rv-path');
|
||||
if (pathLink) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const filePath = pathLink.dataset.path;
|
||||
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||
const copyBtn = ev.target.closest('.rv-copy-btn');
|
||||
if (copyBtn) {
|
||||
@@ -2095,10 +2111,66 @@ class CodemanApp {
|
||||
const renderedText = document.createElement('div');
|
||||
renderedText.className = 'rv-text';
|
||||
renderedText.innerHTML = this._renderMarkdown(text);
|
||||
this._linkifyFilePaths(renderedText);
|
||||
div.appendChild(renderedText);
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make absolute file paths in a rendered message clickable.
|
||||
*
|
||||
* The terminal's link provider never sees these: the response viewer is
|
||||
* markdown, and a path the agent wrote as prose or inline code renders as
|
||||
* inert text — so the file it just produced (a screenshot, a report) was one
|
||||
* copy-paste away from being viewable instead of one click. Same pattern the
|
||||
* terminal uses (constants.js), same destination (the file-preview overlay).
|
||||
*
|
||||
* Walks TEXT NODES and builds anchors with DOM APIs — never innerHTML, and
|
||||
* never a string rebuild of already-sanitized markup: the source is model
|
||||
* output. Subtrees already inside an `<a>` are skipped so an autolinked URL
|
||||
* is never re-cut, and the anchor's textContent is the path verbatim, so
|
||||
* "copy code" still yields exactly what the agent printed.
|
||||
*/
|
||||
_linkifyFilePaths(root) {
|
||||
if (!root || typeof document === 'undefined') return;
|
||||
// Guarded: a stale cached constants.js must degrade to plain text, not throw
|
||||
// out of the middle of rendering a message.
|
||||
if (typeof absoluteFilePathPattern !== 'function') return;
|
||||
const pattern = absoluteFilePathPattern();
|
||||
|
||||
// Collect first: replacing a node while the walker is positioned on it
|
||||
// invalidates the traversal.
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const targets = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (node.parentElement?.closest('a')) continue;
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(node.nodeValue || '')) targets.push(node);
|
||||
}
|
||||
|
||||
for (const node of targets) {
|
||||
const value = node.nodeValue;
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
let match;
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
const path = match[1];
|
||||
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
|
||||
const link = document.createElement('a');
|
||||
link.className = 'rv-path';
|
||||
link.href = '#';
|
||||
link.dataset.path = path;
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
cursor = match.index + path.length;
|
||||
}
|
||||
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
|
||||
node.parentNode?.replaceChild(frag, node);
|
||||
}
|
||||
}
|
||||
|
||||
_getResponseViewerAgentLabel() {
|
||||
const mode = this.sessions.get(this.activeSessionId)?.mode;
|
||||
return mode === 'codex'
|
||||
|
||||
@@ -437,6 +437,94 @@ function computeSseStale(input) {
|
||||
return now - lastMessageAt >= timeoutMs;
|
||||
}
|
||||
|
||||
// Home-screen session order: one comparator for both overviews.
|
||||
//
|
||||
// The phone overview (mobile-overview.js) and the desktop tab rail
|
||||
// (home-sessions.js) list the same sessions, so they answer the same question
|
||||
// and must answer it the same way: "which of these wants me next?".
|
||||
//
|
||||
// 1. Anything blocked on a human first (red question, then error, then a
|
||||
// yellow idle prompt), longest-blocked at the top: a session that has been
|
||||
// sitting on a permission dialog for 20 minutes is starving, one that
|
||||
// raised it 5 seconds ago is not.
|
||||
// 2. Then whatever is running, LONGEST-RUNNING first, since that is the turn most
|
||||
// likely to be finished, or stuck, by the time you look.
|
||||
// 3. Then everything quiet, MOST RECENTLY quiet first: when nothing is
|
||||
// running, the session that just finished is the one you came back for,
|
||||
// and the one you abandoned yesterday sinks.
|
||||
//
|
||||
// So the tiebreak flips direction halfway down the list, and that is the point:
|
||||
// for a state something is still doing, longer = more urgent; for a state
|
||||
// something has stopped in, more recent = more relevant.
|
||||
//
|
||||
// Pure: no DOM, no clock (every input is an epoch-ms stamp already on the
|
||||
// session payload), no `this`. Unit-tested in test/session-overview-order.test.ts.
|
||||
const SESSION_ACTIVITY_RANK = {
|
||||
needs: 0,
|
||||
error: 1,
|
||||
waiting: 2,
|
||||
working: 3,
|
||||
idle: 4,
|
||||
done: 5,
|
||||
};
|
||||
|
||||
/** States still in progress, where the OLDEST stamp sorts first. */
|
||||
const SESSION_ACTIVITY_OLDEST_FIRST = ['needs', 'error', 'waiting', 'working'];
|
||||
|
||||
/**
|
||||
* When the row entered the state it is in.
|
||||
*
|
||||
* For everything quiet that is `lastActivityAt`, the last byte the pane printed:
|
||||
* a Claude pane sitting at its composer prints nothing, so the end of the last
|
||||
* turn is exactly when it went quiet.
|
||||
*
|
||||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||||
* last-activity stamp is always "now" and would rank every running turn as
|
||||
* freshly started. Its real start is the pane's last Enter (`lastSubmitAt`),
|
||||
* persisted server-side and therefore stable across a Codeman restart. A
|
||||
* working pane that has never submitted (spawned with its prompt on the command
|
||||
* line, or an external CLI) falls back to last activity, which puts it at the
|
||||
* short end of the running group rather than falsely at the head of it.
|
||||
*/
|
||||
function sessionActivityAnchor(row) {
|
||||
const activeAt = Number(row && row.lastActivityAt) || 0;
|
||||
if (row && row.state === 'working') return Number(row.lastSubmitAt) || activeAt;
|
||||
return activeAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort comparator for one overview row against another.
|
||||
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} a
|
||||
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} b
|
||||
*/
|
||||
function compareSessionActivity(a, b) {
|
||||
const rankA = SESSION_ACTIVITY_RANK[a.state];
|
||||
const rankB = SESSION_ACTIVITY_RANK[b.state];
|
||||
const rank = (rankA === undefined ? 99 : rankA) - (rankB === undefined ? 99 : rankB);
|
||||
if (rank !== 0) return rank;
|
||||
|
||||
const atA = sessionActivityAnchor(a);
|
||||
const atB = sessionActivityAnchor(b);
|
||||
if (atA !== atB) {
|
||||
// A row with no stamp at all gets no opinion: it sorts last either way
|
||||
// rather than claiming to be the oldest (0) thing on the screen.
|
||||
if (!atA) return 1;
|
||||
if (!atB) return -1;
|
||||
return SESSION_ACTIVITY_OLDEST_FIRST.includes(a.state) ? atA - atB : atB - atA;
|
||||
}
|
||||
|
||||
// Equal stamps (or two unstamped rows): fall back to the user's tab order so
|
||||
// the list is deterministic and cannot shuffle between renders.
|
||||
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
}
|
||||
|
||||
/** Copy of `rows`, in overview order. Never sorts in place, so callers keep their array. */
|
||||
function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -464,6 +552,12 @@ if (typeof window !== 'undefined') {
|
||||
compute: computeSseStale,
|
||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||
};
|
||||
window.CodemanSessionOrder = {
|
||||
RANK: SESSION_ACTIVITY_RANK,
|
||||
anchor: sessionActivityAnchor,
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -893,6 +987,45 @@ function computeRewriteScrollLine(input) {
|
||||
return Math.max(0, (input?.baseY || 0) - linesFromBottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute file paths in agent output, as ONE pattern with two consumers: the
|
||||
* xterm link provider (terminal-ui.js) and the response viewer's markdown
|
||||
* linkifier (app.js). They used to be able to drift, and a path that is
|
||||
* clickable in the terminal but inert in the chat reads as a bug, not a policy.
|
||||
*
|
||||
* Anchored on a known absolute root (so an ordinary fraction or a date can
|
||||
* never match) and terminated by a known extension (so the end of the path is
|
||||
* unambiguous — a trailing `)` or `.` after the extension stays out). Longer
|
||||
* extensions come first in each family (`tsx|ts`), so the trailing `\b` cannot
|
||||
* be satisfied by the shorter branch mid-word.
|
||||
*
|
||||
* ⚠ Consumers must never share one instance: `lastIndex` is per-object state on
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|etc|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
return new RegExp(FILE_PATH_LINK_PATTERN.source, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions the file-preview overlay renders itself. Everything else a link
|
||||
* points at goes to the tail/log viewer, which is the right home for a growing
|
||||
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
|
||||
*/
|
||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov mp3 wav').split(' ')
|
||||
);
|
||||
|
||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||
function previewsInFileViewer(filePath) {
|
||||
const ext = String(filePath || '').split('.').pop().toLowerCase();
|
||||
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
* The welcome screen centers ~560px of content in a window that is usually
|
||||
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
||||
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
|
||||
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
|
||||
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
||||
* one row per live tab.
|
||||
*
|
||||
* Rows are ordered by `CodemanSessionOrder` (constants.js), the same comparator
|
||||
* the phone overview uses: blocked on you first, then running longest-first,
|
||||
* then quiet most-recently-quiet first. The number badge stays the tab-strip
|
||||
* index (Alt+1..9), so it is deliberately NOT sequential down a sorted rail:
|
||||
* it names a shortcut, not a row position.
|
||||
*
|
||||
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
||||
* positioned so the centered welcome content never moves, which means it can
|
||||
@@ -16,11 +21,13 @@
|
||||
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
||||
* a fixed 256px card looks abandoned on a 2560px display.
|
||||
*
|
||||
* Each row carries when the session was FIRST CREATED and when it was LAST
|
||||
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
|
||||
* session emits no event), so a slow clock refreshes them IN PLACE from the
|
||||
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
|
||||
* would restart every row's blink animation and its working ring.
|
||||
* Each row carries when the session was FIRST CREATED and how long it has been
|
||||
* in the state it is in ("created 3d ago · working 12m"), and that second stamp is
|
||||
* the value the order above is computed from, so the rail explains itself
|
||||
* rather than looking arbitrarily shuffled. Both stamps go stale on their own
|
||||
* (a sitting session emits no event), so a slow clock refreshes them IN PLACE
|
||||
* from the epoch-ms values parked on the elements, rather than re-rendering: a
|
||||
* re-render would restart every row's blink animation and its working ring.
|
||||
*
|
||||
* The working state is deliberately identical to the phone's: a pulsing green
|
||||
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
||||
@@ -34,6 +41,7 @@
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
||||
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
||||
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
||||
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
||||
@@ -162,11 +170,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* One row per live session, in the user's tab order. State classification is
|
||||
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
|
||||
* what counts as needing you; the ORDER differs on purpose — the phone sorts
|
||||
* by urgency because it shows one screenful at a time, this column mirrors the
|
||||
* tab strip so the number badges line up with Alt+1..9.
|
||||
* One row per live session, in overview order: whatever is blocked on you
|
||||
* first, then whatever is running (longest turn first), then the quiet ones
|
||||
* most-recently-quiet first. The comparator is `CodemanSessionOrder`
|
||||
* (constants.js), shared with the phone overview, and state classification is
|
||||
* `_mobileOverviewState()` (mobile-overview.js), so the two home screens can
|
||||
* neither disagree about what "working" means nor about what sorts first.
|
||||
*
|
||||
* `orderIndex` stays the position in the TAB STRIP, because that is what the
|
||||
* number badge means (Alt+1..9). Once the rows are sorted those badges no
|
||||
* longer run 1,2,3 down the rail: the badge answers "which key selects this",
|
||||
* not "how far down the list is it".
|
||||
*
|
||||
* @returns {Array<object>} row descriptors, ready to render
|
||||
*/
|
||||
buildHomeSessionRows() {
|
||||
@@ -177,14 +192,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// invisible here while its tab already exists.
|
||||
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
||||
|
||||
return ids.map((id, index) => {
|
||||
const rows = ids.map((id, orderIndex) => {
|
||||
const session = this.sessions.get(id);
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||
const mode = session.mode || 'claude';
|
||||
return {
|
||||
id,
|
||||
index,
|
||||
orderIndex,
|
||||
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
||||
mode,
|
||||
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
||||
@@ -196,8 +211,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// render time so the clock below can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
// The running group is ordered by the pane's last Enter, since a
|
||||
// working pane's last-activity stamp is always "now".
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
// "how long has it been like this", resolved by the phone overview's
|
||||
// helper so both home screens label the same stamp with the same word.
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
};
|
||||
});
|
||||
|
||||
// Guarded like every other constants.js consumer: a stale cached
|
||||
// constants.js (iOS Safari serves old JS after a deploy) must degrade to
|
||||
// tab order, not TypeError the whole home screen away.
|
||||
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(rows) : rows;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -238,32 +264,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
|
||||
* The "created 2h ago · working 12m" footer line. Both stamps keep their raw
|
||||
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
||||
* rewrite the text without rebuilding the row.
|
||||
*
|
||||
* The second stamp is the row's state duration, NOT a plain last-active
|
||||
* stamp: it is the number the rail is sorted by, and a working row that reads
|
||||
* "active just now" (every working pane repaints about once a second) hides
|
||||
* exactly the value that decided its position. `_mobileOverviewSince()` owns
|
||||
* both the word and the anchor, so the phone says the same thing.
|
||||
*/
|
||||
_buildHomeSessionsMeta(row) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'home-sessions-row-meta';
|
||||
// Relative times are generated text, and "created"/"active" here are the
|
||||
// Relative times are generated text, and "created"/"idle" here are the
|
||||
// same generic words that mean something else on other surfaces.
|
||||
meta.setAttribute('data-i18n-skip', '');
|
||||
|
||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
|
||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'ago', 'home-sessions-meta-created'));
|
||||
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'home-sessions-meta-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
meta.appendChild(sep);
|
||||
if (row.since) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'home-sessions-meta-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
meta.appendChild(sep);
|
||||
|
||||
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
|
||||
meta.appendChild(this._buildHomeSessionsStamp(row.since.key, row.since.at, 'for', 'home-sessions-meta-since'));
|
||||
}
|
||||
|
||||
return meta;
|
||||
},
|
||||
|
||||
/** One labelled stamp: a dim key, the relative value, full date in the title. */
|
||||
_buildHomeSessionsStamp(key, timestamp, className) {
|
||||
/** One labelled stamp: a dim key, the value, full date in the title. */
|
||||
_buildHomeSessionsStamp(key, timestamp, format, className) {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = `home-sessions-meta-item ${className}`;
|
||||
|
||||
@@ -274,18 +308,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.dataset.hsTs = String(timestamp || 0);
|
||||
value.textContent = this._homeSessionsAgo(timestamp);
|
||||
value.dataset.hsFmt = format;
|
||||
value.textContent = this._homeSessionsStampText(timestamp, format);
|
||||
wrap.appendChild(value);
|
||||
|
||||
if (timestamp)
|
||||
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
|
||||
if (timestamp) wrap.title = `${key === 'created' ? 'First created' : key}: ${new Date(timestamp).toLocaleString()}`;
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
|
||||
_homeSessionsAgo(timestamp) {
|
||||
if (!timestamp) return '—';
|
||||
return this.formatRelativeTime(timestamp) || '—';
|
||||
/**
|
||||
* 'ago' points at a moment ("3d ago"), 'for' measures a span to now ("12m").
|
||||
* Both come from the phone overview's formatter, so a duration is written the
|
||||
* same way on both home screens.
|
||||
*/
|
||||
_homeSessionsStampText(timestamp, format) {
|
||||
return this._mobileOverviewStampText(timestamp, format);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -315,7 +352,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!el) return;
|
||||
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
||||
const ts = Number(node.dataset.hsTs) || 0;
|
||||
const text = this._homeSessionsAgo(ts);
|
||||
const text = this._homeSessionsStampText(ts, node.dataset.hsFmt);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
},
|
||||
@@ -352,11 +389,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.dataset.hsSession = row.id;
|
||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||
|
||||
if (row.index < 9) {
|
||||
// The badge is the Alt+N key for this tab, so it keeps the tab-strip index
|
||||
// even though the rows are sorted by activity: it will not read 1,2,3 down
|
||||
// the rail, and must not, or the shortcut it names would be wrong.
|
||||
if (row.orderIndex < 9) {
|
||||
const number = document.createElement('span');
|
||||
number.className = 'home-sessions-number';
|
||||
number.setAttribute('data-i18n-skip', '');
|
||||
number.textContent = String(row.index + 1);
|
||||
number.textContent = String(row.orderIndex + 1);
|
||||
item.appendChild(number);
|
||||
}
|
||||
|
||||
|
||||
@@ -2061,6 +2061,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Workspace Hooks</span>
|
||||
<span class="set-row-desc">Install Codeman's hooks in each Claude workspace, so tab alerts, the Approvals Inbox and idle detection also work in linked cases and existing repos. Off leaves your repos untouched.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="remote auto reconnect ssh">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Remote auto-reconnect</span>
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
||||
* WORKING and IDLE / DONE.
|
||||
*
|
||||
* Rows inside a section are ordered by `CodemanSessionOrder` (constants.js),
|
||||
* the SAME comparator the desktop rail uses: blocked longest-first, then
|
||||
* running longest-first, then quiet most-recently-quiet first.
|
||||
*
|
||||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
||||
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
||||
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
||||
@@ -25,6 +29,7 @@
|
||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
@@ -35,16 +40,6 @@
|
||||
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
||||
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
||||
|
||||
/** Sort rank per state: the most demanding thing sorts first inside a section. */
|
||||
const MOBILE_OVERVIEW_STATE_RANK = {
|
||||
needs: 0,
|
||||
error: 1,
|
||||
waiting: 2,
|
||||
working: 3,
|
||||
idle: 4,
|
||||
done: 5,
|
||||
};
|
||||
|
||||
/** How many past conversations show before the "Show all" toggle. */
|
||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||
|
||||
@@ -123,14 +118,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||||
* last-activity stamp is always "now" and would report every running turn as
|
||||
* 0m. The turn's own start is the pane's last Enter (`lastSubmitAt`), which is
|
||||
* persisted server-side and therefore survives a Codeman restart. A session
|
||||
* that has never submitted has no anchor at all, and gets no stamp rather than
|
||||
* a made-up one.
|
||||
* persisted server-side and therefore survives a Codeman restart. A working
|
||||
* session with NO submit stamp falls back to `lastActivityAt`, because that is
|
||||
* exactly what `sessionActivityAnchor` (constants.js) sorts it by: a row must
|
||||
* never be ranked by a number it does not show.
|
||||
*
|
||||
* @returns {{key: string, at: number}|null}
|
||||
*/
|
||||
_mobileOverviewSince(state, session) {
|
||||
const at = state === 'working' ? Number(session.lastSubmitAt) || 0 : Number(session.lastActivityAt) || 0;
|
||||
const activeAt = Number(session.lastActivityAt) || 0;
|
||||
const at = state === 'working' ? Number(session.lastSubmitAt) || activeAt : activeAt;
|
||||
if (!at) return null;
|
||||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||||
},
|
||||
@@ -188,18 +185,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
// Raw stamps for the shared order comparator; `since` above is the same
|
||||
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const bySeverityThenOrder = (a, b) => {
|
||||
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
|
||||
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
|
||||
// Order is `CodemanSessionOrder` (constants.js), shared with the desktop
|
||||
// rail: blocked first (longest-blocked at the top), then running
|
||||
// longest-first, then quiet most-recent-first.
|
||||
// Guarded: a stale cached constants.js (iOS Safari after a deploy) must
|
||||
// degrade to tab order, not TypeError the overview away.
|
||||
const inSection = (states) => {
|
||||
const filtered = rows.filter((r) => states.includes(r.state));
|
||||
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(filtered) : filtered;
|
||||
};
|
||||
|
||||
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
|
||||
|
||||
// Past = conversations from the unified list that are not currently live.
|
||||
// The endpoint already folds a transcript into its owning session (via the
|
||||
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
||||
|
||||
+112
-3
@@ -15,6 +15,11 @@
|
||||
|
||||
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
|
||||
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
|
||||
// Bounds for the by-id text preview, mirroring what the workspace text preview
|
||||
// already does server-side (500 lines). The byte cap rides a Range request, so
|
||||
// a huge log is a partial read rather than a download the viewer throws away.
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
||||
const TEXT_PREVIEW_MAX_LINES = 500;
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -3234,6 +3239,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a path is absolute and provably OUTSIDE this session's workspace.
|
||||
*
|
||||
* `file-content` / `file-raw` resolve every path against `workingDir` and
|
||||
* refuse anything that escapes it, so an absolute path elsewhere on the host
|
||||
* (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can
|
||||
* only ever 404 there — it has to go through the attachment routes instead.
|
||||
*
|
||||
* A string compare is enough for ROUTING; the real containment decision stays
|
||||
* server-side (realpath + guard) on whichever route the request lands on. An
|
||||
* unknown workingDir answers false, leaving the historical path untouched.
|
||||
*/
|
||||
_isExternalPreviewPath(filePath, sessionId) {
|
||||
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
|
||||
const workingDir = this.sessions.get(sessionId)?.workingDir;
|
||||
if (!workingDir) return false;
|
||||
const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
|
||||
return filePath !== workingDir && !filePath.startsWith(root);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register an out-of-workspace path as a live external attachment and return
|
||||
* its id, so the preview can render it through the by-id attachment routes.
|
||||
*
|
||||
* `notify: false` keeps this quiet: the caller is already opening the file in
|
||||
* the overlay, so the usual attachment card + unread badge would be noise on
|
||||
* top of the thing the user just asked to see. The server still enforces the
|
||||
* full attachment guard (blocked secret trees, extension allowlist, symlinks
|
||||
* resolved), so a refusal here is a policy answer worth showing verbatim.
|
||||
*
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
|
||||
*/
|
||||
async _registerExternalPreview(filePath, sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath, notify: false }),
|
||||
});
|
||||
const result = await res.json().catch(() => null);
|
||||
if (res.ok && result?.success && result.data?.attachmentId) {
|
||||
return { attachmentId: result.data.attachmentId, size: result.data.size || 0 };
|
||||
}
|
||||
const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
|
||||
// The registry's type answer is a policy term, not an explanation, and the
|
||||
// user just clicked a file they can see on disk. Say what IS previewable
|
||||
// from outside the workspace instead.
|
||||
if (/unsupported/i.test(reason)) {
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
return {
|
||||
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
|
||||
};
|
||||
}
|
||||
return { error: reason };
|
||||
} catch (err) {
|
||||
return { error: err.message || 'Cannot open this file' };
|
||||
}
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
if (!sessionId || !filePath) return;
|
||||
|
||||
@@ -3258,25 +3322,70 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
|
||||
// Out-of-workspace path: mint an attachment id up front. Every branch below
|
||||
// talks to a workspace-confined route, so without this the image/PDF ones
|
||||
// render a broken frame and the text one reports a bare "File not found"
|
||||
// for a file that is sitting right there on disk.
|
||||
let externalError = '';
|
||||
let externalSize = 0;
|
||||
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
const external = await this._registerExternalPreview(filePath, sessionId);
|
||||
attachmentId = external.attachmentId || null;
|
||||
externalError = external.error || '';
|
||||
externalSize = external.size || 0;
|
||||
}
|
||||
if (!attachmentId && externalError) {
|
||||
footerEl.textContent = '';
|
||||
bodyEl.innerHTML = `<div class="binary-message">${escapeHtml(externalError)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
||||
// Size when we just registered the file ourselves, so a path opened from a
|
||||
// link reads like a workspace preview instead of a bare "PNG". History
|
||||
// cards arrive with an id and no size and keep the short form.
|
||||
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||
} else if (VIDEO_EXTS.has(ext)) {
|
||||
// Same markup as the workspace branch below, including playsinline: iOS
|
||||
// otherwise hijacks playback into its own fullscreen player, which
|
||||
// leaves this overlay behind it with no way back but its close button.
|
||||
// The attachment raw route is range-aware, so the scrub bar works.
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(`${base}/raw`)}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
} else if (AUDIO_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(`${base}/raw`)}" controls autoplay preload="metadata"></audio>`;
|
||||
} else if (ext === 'pdf') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else if (ext === 'docx' || ext === 'pptx') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${base}/raw`);
|
||||
// Bounded like the workspace text preview: a Range for the first
|
||||
// chunk (the route is range-aware, so this is a real partial read,
|
||||
// not a 50MB download thrown away) and a line cap on top. An agent's
|
||||
// log can be enormous, and rendering all of it into one <pre> is how
|
||||
// you lock up the tab on the file you wanted to glance at.
|
||||
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
|
||||
if (!res.ok) throw new Error('Failed to load attachment');
|
||||
const text = await res.text();
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
|
||||
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
|
||||
this.filePreviewContent = shown;
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
|
||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||
}
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
|
||||
@@ -411,6 +411,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false;
|
||||
// Default ON: an absent key is a user who has never seen this setting, and OFF
|
||||
// for them means no tab alerts in any workspace Codeman did not scaffold.
|
||||
document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
@@ -2020,6 +2023,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
|
||||
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
|
||||
@@ -9861,13 +9861,18 @@ kbd {
|
||||
|
||||
/* ========== File Preview Overlay ========== */
|
||||
|
||||
/* Above the response viewer (5000) and its backdrop (4999): a file path in the
|
||||
chat opens this overlay, and at the old 2000 it rendered BEHIND the panel it
|
||||
was launched from — the click looked dead. Same relationship the path picker
|
||||
and its preview already have (10020 / 10030). Still below the toast and
|
||||
picker band (10000+), so a "Saved" toast keeps landing on top. */
|
||||
.file-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--modal-backdrop);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 2000;
|
||||
z-index: 5100;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -12419,6 +12424,16 @@ kbd {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* File paths linkified out of the message text. Monospace so a path still reads
|
||||
as a path in prose, and break-all because these are long and the viewer is
|
||||
narrow on a phone. Colour/underline come from the .rv-text a rule above. */
|
||||
.rv-text a.rv-path {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
|
||||
font-size: 0.92em;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */
|
||||
.rv-table-wrap {
|
||||
margin: 1em 0;
|
||||
@@ -14908,8 +14923,9 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
}
|
||||
|
||||
/* The freshest signal on the row: while a session is actually doing something,
|
||||
its "active" stamp is the one the eye should land on. */
|
||||
.home-sessions-row--working .home-sessions-meta-active {
|
||||
how long it has been doing it is what the eye should land on (and it is what
|
||||
the rail is sorted by). */
|
||||
.home-sessions-row--working .home-sessions-meta-since {
|
||||
color: var(--green);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
@@ -1434,19 +1434,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions.
|
||||
// Image/PDF extensions are included so pasted-attachment paths
|
||||
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
|
||||
// rather than the log viewer (see addLink).
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
|
||||
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
|
||||
// included so pasted-attachment paths (`.claude-images/paste-*.png`) and
|
||||
// screenshots an agent just wrote are clickable; those open the file
|
||||
// preview rather than the log viewer (see addLink).
|
||||
//
|
||||
// The literal lives in constants.js because the response viewer linkifies
|
||||
// the SAME paths out of markdown — one definition, two consumers. A fresh
|
||||
// instance per call: `lastIndex` is per-object state.
|
||||
const extPattern = absoluteFilePathPattern();
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
/** Extensions that should open the image/document preview, not the log viewer. */
|
||||
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
@@ -1465,9 +1465,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
activate(event, text) {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images and PDFs inline.
|
||||
const ext = (text.split('.').pop() || '').toLowerCase();
|
||||
if (PREVIEW_EXTS.has(ext)) {
|
||||
// already renders images, PDFs, documents and media inline — and it
|
||||
// now reaches files outside the workspace too, which is where an
|
||||
// agent's screenshots and scratchpad captures actually land.
|
||||
//
|
||||
// Text goes to the log viewer, which follows a file that is still
|
||||
// being written — but ONLY where it can actually read: it spawns
|
||||
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
|
||||
// out-of-workspace path there answered "Path must be within
|
||||
// working directory or allowed log directories" while the SAME
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,15 @@ import type {
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AUDIO_ATTACHMENT_EXTENSIONS,
|
||||
AttachmentRegistrationError,
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
@@ -67,6 +70,22 @@ const MIME_TYPES: Record<string, string> = {
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
// Media needs a real type, not the octet-stream fallback: a <video>/<audio>
|
||||
// element refuses to decode an unknown type, so a missing entry here presents
|
||||
// as a player that renders and then does nothing.
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/x-m4v',
|
||||
ogv: 'video/ogg',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
opus: 'audio/opus',
|
||||
pdf: 'application/pdf',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
@@ -175,10 +194,16 @@ async function serveRawFile(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (download || extension === 'svg') {
|
||||
// Markup is download-only: served with a renderable type on our own origin it
|
||||
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
|
||||
// family is servable, so widening what can be READ never widened what can RUN.
|
||||
// The preview overlay reads these through `fetch()`, which ignores the
|
||||
// disposition, so a clicked .html still shows its source.
|
||||
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
|
||||
if (download || markupOnly) {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
markupOnly ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -186,6 +211,17 @@ async function serveRawFile(
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain text with no dedicated MIME entry (code, config, logs, csv, xml) goes
|
||||
// out as inert text/plain rather than the octet-stream fallback, matching what
|
||||
// the path picker already does. Never a type the browser would execute.
|
||||
if (!MIME_TYPES[extension] && TEXT_ATTACHMENT_EXTENSIONS.has(extension)) {
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -1099,8 +1135,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// so the file viewer can open the same files.
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
||||
// Shared with the attachment registry so a video plays the same whether it
|
||||
// sits in the workspace or is reached by id from outside it.
|
||||
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
|
||||
const audioExts = AUDIO_ATTACHMENT_EXTENSIONS;
|
||||
const otherBinaryExts = new Set([
|
||||
'pdf',
|
||||
'zip',
|
||||
@@ -1449,7 +1487,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const body = (req.body || {}) as { path?: string };
|
||||
const body = (req.body || {}) as { path?: string; notify?: boolean };
|
||||
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
|
||||
@@ -1458,7 +1496,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
try {
|
||||
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
|
||||
ctx.broadcast(SseEvent.AttachmentDetected, event);
|
||||
// `notify: false` registers QUIETLY. The file-preview overlay uses it to
|
||||
// mint an id for a path the user just clicked (a terminal or response-viewer
|
||||
// link pointing outside the workspace): it is already opening the file, so
|
||||
// the attachment card + unread badge would be noise announcing what is
|
||||
// filling the screen. Default stays true — every other caller (the
|
||||
// `codeman attach` CLI, codeman-publish) wants the card.
|
||||
if (body.notify !== false) {
|
||||
ctx.broadcast(SseEvent.AttachmentDetected, event);
|
||||
}
|
||||
return { success: true, data: event };
|
||||
} catch (err) {
|
||||
if (err instanceof AttachmentRegistrationError) {
|
||||
|
||||
@@ -148,6 +148,11 @@ export function registerHookEventRoutes(
|
||||
...safeData,
|
||||
...(approvalId && { approvalId }),
|
||||
});
|
||||
// Full state ride-along, same shape as the working/idle handlers: the home
|
||||
// screens rank the blocked group on lastActivityAt, and without this a
|
||||
// permission prompt raised after page load kept ranking by whatever stamp
|
||||
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
|
||||
ctx.broadcastSessionStateDebounced(sessionId);
|
||||
|
||||
// Send push notifications for hook events
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
applyAgentSkill,
|
||||
refreshUserAgentSkill,
|
||||
seedAgentSessionPreamble,
|
||||
ensureCodemanHooks,
|
||||
refreshStaleCodemanHooks,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -625,6 +626,32 @@ async function injectAgentSkill(casePath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
|
||||
* shared by every create path, so the setting cannot apply to some of them only.
|
||||
*
|
||||
* ON (`workspaceHooksEnabled`, the default): INSTALL Codeman's hooks block, merging
|
||||
* so a user's own hook entries and every other settings key survive. Hooks were
|
||||
* previously written only when Codeman CREATED the case DIRECTORY, so a linked case
|
||||
* or any pre-existing repo — where most sessions actually run — had none, and every
|
||||
* hook-driven surface was silently dead there: no tab alert or phone-overview row
|
||||
* when a dialog blocks the pane, no Approvals Inbox item, no push, no definitive
|
||||
* `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for the wait endpoints.
|
||||
* Measured 2026-08-15 in a linked case: an AskUserQuestion dialog on screen with the
|
||||
* tab reporting a calm `idle`. Claude Code re-reads the file, so a session already
|
||||
* running in that workspace starts firing hooks without a restart (verified live).
|
||||
*
|
||||
* OFF: the older, narrower behavior. A Codeman block that is already there is still
|
||||
* refreshed when stale (COD-91: a pre-secret block 401s once the hook-secret gate
|
||||
* went unconditional), but one is never added, so Codeman leaves the repo alone.
|
||||
*
|
||||
* Best-effort either way: a refusal or a thrown error must never fail the create.
|
||||
*/
|
||||
async function applyWorkspaceHooks(ctx: ConfigPort, workspace: string): Promise<void> {
|
||||
const install = await ctx.getWorkspaceHooksEnabled();
|
||||
await (install ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace)).catch(() => {});
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
@@ -765,11 +792,14 @@ export function registerSessionRoutes(
|
||||
await applyStatusLineConfig(workingDir, true);
|
||||
}
|
||||
|
||||
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
|
||||
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
|
||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||||
// Hooks for the workspace this session runs in (install vs refresh-only is the
|
||||
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote
|
||||
// attach (workingDir is a user@host:session pseudo-path — mkdir would create it
|
||||
// as a junk local dir), and only when the caller named a workingDir: the
|
||||
// process-cwd fallback is $HOME under installer-created services, and hooks
|
||||
// materializing in ~/.claude/settings.local.json was never asked for.
|
||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
||||
await applyWorkspaceHooks(ctx, workingDir);
|
||||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
||||
// .claude rationale as the statusLine above: a create must never remove the
|
||||
// skill from under other live sessions in the repo. Marker-guarded, so a
|
||||
@@ -2899,11 +2929,17 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (!remote && !docker && mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
// EXISTING case directory (a linked case, a cloned repo, anything Codeman did
|
||||
// not scaffold): install-or-refresh per the setting (see applyWorkspaceHooks).
|
||||
// Other modes keep the narrower COD-91 self-heal unconditionally: only claude
|
||||
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
|
||||
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
||||
// doesn't exist on the local filesystem.
|
||||
if (mode === 'claude') {
|
||||
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||
@@ -2936,7 +2972,10 @@ export function registerSessionRoutes(
|
||||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
// A settings file with no hooks in it is the same dead-surface case as a
|
||||
// linked case. This branch is already gated on `docker.hooksEnabled`, and
|
||||
// applyWorkspaceHooks adds the user-level gate on top.
|
||||
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — the session still runs, hooks may be degraded */
|
||||
|
||||
@@ -906,6 +906,17 @@ export const SettingsUpdateSchema = z
|
||||
* add-only at create; a marker keeps user-authored copies untouched.
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Install Codeman's hooks block into the workspace of every Claude session,
|
||||
* not only into cases Codeman scaffolded itself. SYNCED, default ON: without
|
||||
* it a linked case or an existing repo runs with no hooks at all, and each
|
||||
* hook-driven surface is silently dead there (tab alert, Approvals Inbox,
|
||||
* push, respawn's definitive idle signals, the wait endpoints' stop/blocked).
|
||||
* Turning it OFF restores the older, narrower behavior — a Codeman hooks
|
||||
* block that is already present is still refreshed when stale, but one is
|
||||
* never added — for a user who wants Codeman to leave their repos alone.
|
||||
*/
|
||||
workspaceHooksEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Let browser dictation transcribe through this machine's Claude Code login,
|
||||
* the same speech-to-text service the CLI's own `/voice` mode uses
|
||||
|
||||
@@ -80,6 +80,23 @@ const SENSITIVE_PATTERNS: RegExp[] = [
|
||||
/\/\.claude\/\.credentials\.json$/,
|
||||
/\/\.codeman[^/]*\/hook-secret$/,
|
||||
/\/\.codeman[^/]*\/users\.json$/,
|
||||
// Codeman's own state files. Named once `.json` became previewable outside
|
||||
// the workspace: `SessionState.envOverrides` persists whatever the user set
|
||||
// for a session, and the env allowlist admits key-shaped names
|
||||
// (`GEMINI_API_KEY`, `CLAUDE_CODE_*`), so state can hold a live credential.
|
||||
// `state[^/]*` rather than `state`: siblings like state-inner.json carry the
|
||||
// same payload. Same reasoning as the two entries above, and it leaves the
|
||||
// rest of ~/.codeman attachable.
|
||||
/\/\.codeman[^/]*\/state[^/]*\.json$/,
|
||||
// settings.json holds a credential BY SCHEMA (`voiceSettings.apiKey`, the
|
||||
// Deepgram key); push-keys.json holds the VAPID PRIVATE key (enough to forge
|
||||
// push notifications to every subscribed device); intents.json is written
|
||||
// 0600 precisely because captured prompts can contain secrets, and is
|
||||
// deliberately kept out of /api/search — it must not be readable through a
|
||||
// different route instead.
|
||||
/\/\.codeman[^/]*\/settings\.json$/,
|
||||
/\/\.codeman[^/]*\/push-keys\.json$/,
|
||||
/\/\.codeman[^/]*\/intents\.json$/,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,6 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { ensureCodemanHooks } from '../hooks-config.js';
|
||||
import { PushSubscriptionStore } from '../push-store.js';
|
||||
import webpush from 'web-push';
|
||||
import { SseStreamManager } from './sse-stream-manager.js';
|
||||
@@ -636,6 +637,7 @@ export class WebServer extends EventEmitter {
|
||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
|
||||
getWorkspaceHooksEnabled: this.getWorkspaceHooksEnabled.bind(this),
|
||||
getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this),
|
||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||
getLightState: this.getLightState.bind(this),
|
||||
@@ -1710,6 +1712,16 @@ export class WebServer extends EventEmitter {
|
||||
return settings.agentSkillEnabled === true;
|
||||
}
|
||||
|
||||
// Whether a Claude session installs Codeman's hooks block into its workspace
|
||||
// (synced `workspaceHooksEnabled` setting). Default ON — an absent key means a
|
||||
// user who has never seen this setting, and OFF for them would mean no tab
|
||||
// alerts, no Approvals Inbox and no respawn idle signals in every workspace
|
||||
// Codeman did not scaffold itself.
|
||||
private async getWorkspaceHooksEnabled(): Promise<boolean> {
|
||||
const settings = await this.readSettings();
|
||||
return settings.workspaceHooksEnabled !== false;
|
||||
}
|
||||
|
||||
// Whether browser dictation may use this machine's Claude Code credentials
|
||||
// (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md).
|
||||
// OFF by default because turning it on spends the operator's Claude subscription
|
||||
@@ -2645,6 +2657,11 @@ export class WebServer extends EventEmitter {
|
||||
// the launch conversation until the user types again, even though
|
||||
// the re-attached CLI is on a post-`/clear` one.
|
||||
lastSubmitAt: savedState?.lastSubmitAt,
|
||||
// The pane's last output, previous run's value. Without it every
|
||||
// restart restamped all sessions "now" (constructor + the attach
|
||||
// repaint within the same second), flattening the home screens'
|
||||
// most-recently-quiet ordering to tab order after each deploy.
|
||||
lastActivityAt: savedState?.lastActivityAt,
|
||||
// Remote SSH metadata must round-trip on recovery: without it the
|
||||
// attach cwd falls back to the (nonexistent-locally) remote path and
|
||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
||||
@@ -2819,6 +2836,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions recovered from a previous run predate the create-path hook
|
||||
// install, and these are long-lived: by the time a server restart comes
|
||||
// round a session may be days old and has been running hook-blind the
|
||||
// whole time. Claude Code re-reads settings.local.json, so writing the
|
||||
// block now arms the RUNNING CLI, no session restart needed.
|
||||
await this.ensureHooksForRecoveredWorkspaces();
|
||||
|
||||
// Start stats collection for mux sessions
|
||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
}
|
||||
@@ -2845,6 +2869,37 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Codeman's hooks into the workspaces of the sessions just recovered.
|
||||
*
|
||||
* Deduped by workspace, because sessions in one repo share a single
|
||||
* `.claude/settings.local.json` and the write is otherwise repeated per tab.
|
||||
* Claude mode only (nothing else reads `.claude` hooks), never for remote
|
||||
* sessions (their `workingDir` is a path on ANOTHER host, so writing it here
|
||||
* would scaffold a stray directory locally), and never for a docker case that
|
||||
* opted out of hooks.
|
||||
*
|
||||
* Failures are swallowed per workspace: `ensureCodemanHooks` already refuses
|
||||
* unsafe targets with a warning, and a workspace we cannot write to must not
|
||||
* stop the rest of recovery.
|
||||
*
|
||||
* Skipped entirely when `workspaceHooksEnabled` is OFF: that setting exists so a
|
||||
* user can keep Codeman out of their repos, and a boot-time sweep is the last
|
||||
* place that should ignore it.
|
||||
*/
|
||||
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
|
||||
if (!(await this.getWorkspaceHooksEnabled())) return;
|
||||
const workspaces = new Set<string>();
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.mode !== 'claude' || session.remote) continue;
|
||||
if (session.docker && !session.docker.hooksEnabled) continue;
|
||||
if (session.workingDir) workspaces.add(session.workingDir);
|
||||
}
|
||||
for (const workspace of workspaces) {
|
||||
await ensureCodemanHooks(workspace).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
|
||||
* the dropped remote session and report the outcome back to the watcher so it
|
||||
|
||||
@@ -224,6 +224,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// re-capture instead).
|
||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
// Full state ride-along: the home screens sort the running group on
|
||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||
// with (a turn started after page load ranks by the PREVIOUS turn's
|
||||
// Enter). Debounced, so working-signal flaps cost one broadcast.
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
|
||||
+120
-19
@@ -2,11 +2,11 @@
|
||||
//
|
||||
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
||||
// the welcome overlay's left gutter. Two things about it can silently go wrong
|
||||
// and are pinned here: the row ORDER (it mirrors the tab strip, unlike the phone
|
||||
// overview which sorts by urgency, and the number badges are only correct if it
|
||||
// does), and the WIDTH GATE, which lives in two places at once — the JS constant
|
||||
// and a CSS media query — because the column is absolutely positioned and would
|
||||
// overlap the search panel in a narrow window.
|
||||
// and are pinned here: the row ORDER (shared with the phone overview via
|
||||
// CodemanSessionOrder, with the number badge still carrying the TAB index so
|
||||
// Alt+N keeps working), and the WIDTH GATE, which lives in two places at once —
|
||||
// the JS constant and a CSS media query — because the column is absolutely
|
||||
// positioned and would overlap the search panel in a narrow window.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
@@ -35,9 +35,10 @@ function fakeElement(): any {
|
||||
|
||||
/**
|
||||
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
||||
* `shouldUseMobileOverview` from mobile-overview.js, so both files run in the
|
||||
* same context — which is also the point: if that reuse ever breaks, these
|
||||
* tests stop loading rather than quietly testing a divergent copy.
|
||||
* `shouldUseMobileOverview` from mobile-overview.js and the row comparator from
|
||||
* constants.js, so all three files run in the same context, which is also the
|
||||
* point: if that reuse ever breaks, these tests stop loading rather than
|
||||
* quietly testing a divergent copy.
|
||||
*/
|
||||
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
@@ -52,7 +53,7 @@ function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
||||
});
|
||||
for (const file of ['mobile-overview.js', 'home-sessions.js']) {
|
||||
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
|
||||
@@ -76,9 +77,9 @@ function sessionMap(list: Array<Record<string, any>>) {
|
||||
}
|
||||
|
||||
describe('home sessions column: model', () => {
|
||||
it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => {
|
||||
// The phone overview would hoist 'needy' to the top; this surface must not,
|
||||
// because its badges are the Alt+N indices.
|
||||
it('hoists a session blocked on you, and keeps its badge on the TAB index', () => {
|
||||
// The badge names the Alt+N shortcut, so a sorted rail shows 2,1,3 rather
|
||||
// than renumbering itself 1,2,3 and lying about which key selects what.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
||||
sessionOrder: ['first', 'needy', 'third'],
|
||||
@@ -87,10 +88,34 @@ describe('home sessions column: model', () => {
|
||||
});
|
||||
|
||||
const rows = app.buildHomeSessionRows();
|
||||
expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']);
|
||||
expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]);
|
||||
expect(rows[1].state).toBe('needs');
|
||||
expect(rows[1].pill).toBe('needs you');
|
||||
expect(rows.map((r: any) => r.id)).toEqual(['needy', 'first', 'third']);
|
||||
expect(rows.map((r: any) => r.orderIndex)).toEqual([1, 0, 2]);
|
||||
expect(rows[0].state).toBe('needs');
|
||||
expect(rows[0].pill).toBe('needs you');
|
||||
});
|
||||
|
||||
it('orders running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
||||
// The same rule the phone overview follows, and the reason the rail exists:
|
||||
// what is running longest is what is most likely to be done or stuck, and
|
||||
// once nothing is running the session that just stopped is the one you came
|
||||
// back for.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([
|
||||
{ id: 'young-turn', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
||||
{ id: 'old-turn', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||
{ id: 'stale-idle', status: 'idle', lastActivityAt: 2_000 },
|
||||
{ id: 'fresh-idle', status: 'idle', lastActivityAt: 8_000 },
|
||||
]),
|
||||
sessionOrder: ['young-turn', 'old-turn', 'stale-idle', 'fresh-idle'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual([
|
||||
'old-turn',
|
||||
'young-turn',
|
||||
'fresh-idle',
|
||||
'stale-idle',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a session that is not in the order list yet', () => {
|
||||
@@ -117,11 +142,13 @@ describe('home sessions column: model', () => {
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
// Unstamped rows fall back to the tab order inside a state, so this reads
|
||||
// as the state ranking alone: an errored session is blocked on you.
|
||||
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
||||
['error', 'error'],
|
||||
['working', 'working'],
|
||||
['idle', 'idle'],
|
||||
['done', 'done'],
|
||||
['error', 'error'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -236,8 +263,82 @@ describe('home sessions column: wiring', () => {
|
||||
expect(aside).toBeGreaterThan(overlayStart);
|
||||
expect(aside).toBeLessThan(content);
|
||||
// Load order: the module reuses prototype methods installed by
|
||||
// mobile-overview.js. Compare the <script> tags, not any mention: both
|
||||
// files are named in explanatory comments earlier in the document.
|
||||
// mobile-overview.js and the comparator installed by constants.js. Compare
|
||||
// the <script> tags, not any mention: both files are named in explanatory
|
||||
// comments earlier in the document.
|
||||
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
||||
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('home screens: one order, one numbering', () => {
|
||||
it('produces the same order on the rail and the phone overview for one input', () => {
|
||||
// Both surfaces claim to share CodemanSessionOrder. Nothing used to assert
|
||||
// they actually produce one order for one input, so a future local sort in
|
||||
// either builder would silently split them. The rail is one list; the phone
|
||||
// splits NEEDS YOU / CURRENT, so rail order must equal the concatenation.
|
||||
const fixture = [
|
||||
{ id: 'blocked-new', lastActivityAt: 5_000 },
|
||||
{ id: 'idle-old', lastActivityAt: 3_000 },
|
||||
{ id: 'run-new', status: 'busy', lastSubmitAt: 8_000, lastActivityAt: 9_500 },
|
||||
{ id: 'blocked-old', lastActivityAt: 1_000 },
|
||||
{ id: 'run-old', status: 'busy', lastSubmitAt: 2_000, lastActivityAt: 9_600 },
|
||||
{ id: 'idle-new', lastActivityAt: 9_000 },
|
||||
];
|
||||
const pendingHooks = new Map([
|
||||
['blocked-new', new Set(['permission_prompt'])],
|
||||
['blocked-old', new Set(['permission_prompt'])],
|
||||
]);
|
||||
const sessionOrder = fixture.map((s) => s.id);
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap(fixture),
|
||||
sessionOrder,
|
||||
cases: CASES,
|
||||
pendingHooks,
|
||||
});
|
||||
|
||||
const railIds = app.buildHomeSessionRows().map((r: any) => r.id);
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: app.sessions,
|
||||
cases: CASES,
|
||||
sessionOrder,
|
||||
pendingHooks,
|
||||
});
|
||||
const phoneIds = [...model.needsYou, ...model.current].map((r: any) => r.id);
|
||||
|
||||
expect(railIds).toEqual(phoneIds);
|
||||
// And the shared order is the documented one: blocked longest-first, then
|
||||
// running longest-first, then quiet newest-first.
|
||||
expect(railIds).toEqual(['blocked-old', 'blocked-new', 'run-old', 'run-new', 'idle-new', 'idle-old']);
|
||||
});
|
||||
|
||||
it('numbers rows over the LIVE projection when sessionOrder holds a dead id', () => {
|
||||
// sessionOrder can transiently contain a deleted session (delete raced the
|
||||
// order sync). The strip paints numbers over live sessions only, and the
|
||||
// Alt+digit handler resolves through the same projection, so the rail must
|
||||
// number alpha=1, beta=2 with no hole where the ghost sits.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'alpha' }, { id: 'beta' }]),
|
||||
sessionOrder: ['ghost', 'alpha', 'beta'],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(app.buildHomeSessionRows().map((r: any) => [r.id, r.orderIndex])).toEqual([
|
||||
['alpha', 0],
|
||||
['beta', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Alt+digit resolves through the live-session projection in app.js', () => {
|
||||
// Static guard for the handler half of the invariant above: the digit
|
||||
// branch must filter sessionOrder against live sessions before indexing,
|
||||
// for sessions AND for the web-tab continuation.
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = appJs.indexOf('^Digit([1-9])$');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const branch = appJs.slice(start, start + 1200);
|
||||
expect(branch).toContain('this.sessionOrder.filter((id) => this.sessions.has(id))');
|
||||
expect(branch).toContain('idx < live.length');
|
||||
expect(branch).toContain('idx - live.length');
|
||||
expect(branch).not.toContain('this.sessionOrder[idx]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,18 +18,25 @@ import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SOURCE = readFileSync(join(__dirname, '..', 'src', 'web', 'public', 'terminal-ui.js'), 'utf-8');
|
||||
const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
|
||||
|
||||
/** Extract `const <name> = /.../g;` from the shipped source and build the RegExp. */
|
||||
const SOURCE = publicFile('terminal-ui.js');
|
||||
// The file-path pattern lives in constants.js: the response viewer linkifies the
|
||||
// same paths out of markdown, and one definition is what keeps a path that is
|
||||
// clickable in the terminal from being inert in the chat.
|
||||
const CONSTANTS_SOURCE = publicFile('constants.js');
|
||||
|
||||
/** Extract `const <name> = /.../g;` from the shipped sources and build the RegExp. */
|
||||
function shippedPattern(name: string): RegExp {
|
||||
const m = SOURCE.match(new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`));
|
||||
if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js`);
|
||||
const literal = new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`);
|
||||
const m = SOURCE.match(literal) ?? CONSTANTS_SOURCE.match(literal);
|
||||
if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js or constants.js`);
|
||||
const lit = m[1];
|
||||
const lastSlash = lit.lastIndexOf('/');
|
||||
return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
|
||||
}
|
||||
|
||||
const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'extPattern', 'bashPattern'];
|
||||
const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'FILE_PATH_LINK_PATTERN', 'bashPattern'];
|
||||
|
||||
/** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */
|
||||
const KILLER_LINES = [
|
||||
@@ -116,15 +123,24 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('extPattern links pasted image/PDF attachment paths', () => {
|
||||
it('the file-path pattern links pasted image/PDF/media attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('extPattern');
|
||||
const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
// An agent's own scratchpad capture — the path shape this whole feature
|
||||
// exists for, and the one that used to open a "File not found" preview.
|
||||
'/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png',
|
||||
// macOS and WSL roots: unmatched before, so Mac users had no clickable
|
||||
// paths at all outside /var and /tmp.
|
||||
'/Users/arbbot/codeman-cases/report.docx',
|
||||
'/mnt/d/captures/demo.mp4',
|
||||
// Longer extension of a family must win over its prefix (tsx over ts).
|
||||
'/home/a/src/App.tsx',
|
||||
];
|
||||
for (const path of cases) {
|
||||
ext.lastIndex = 0;
|
||||
@@ -134,6 +150,13 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('terminal-ui builds its path pattern from the shared factory', () => {
|
||||
// Structural guard: a local literal here would drift from the response
|
||||
// viewer's linkifier, which is the divergence the move exists to prevent.
|
||||
expect(SOURCE).toContain('absoluteFilePathPattern()');
|
||||
expect(SOURCE).not.toMatch(/const extPattern =\s*\n?\s*\//);
|
||||
});
|
||||
|
||||
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
|
||||
// structural guard: the dangerous construct is an empty-matchable token
|
||||
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
|
||||
|
||||
@@ -42,9 +42,12 @@ function loadOverviewApp(overrides: Record<string, any> = {}) {
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => 'mobile' },
|
||||
});
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
|
||||
filename: 'mobile-overview.js',
|
||||
});
|
||||
// constants.js first: it installs the row comparator (window.CodemanSessionOrder)
|
||||
// that buildMobileOverviewModel() sorts every section with, shared with the
|
||||
// desktop rail so the two home screens cannot order the same list differently.
|
||||
for (const file of ['constants.js', 'mobile-overview.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
||||
@@ -123,7 +126,7 @@ describe('mobile overview model', () => {
|
||||
expect(model.sessionCount).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the user tab order as the tiebreak inside a section', () => {
|
||||
it('keeps the user tab order as the tiebreak when nothing is stamped', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
||||
@@ -134,6 +137,42 @@ describe('mobile overview model', () => {
|
||||
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
||||
});
|
||||
|
||||
it('sorts running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
||||
// A working pane repaints about once a second, so its last-activity stamp
|
||||
// is always "now": the running group has to key off the pane's last Enter
|
||||
// instead, or every turn ranks as freshly started.
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
session({ id: 'quiet-old', status: 'idle', lastActivityAt: 2_000 }),
|
||||
session({ id: 'turn-young', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 }),
|
||||
session({ id: 'quiet-new', status: 'idle', lastActivityAt: 8_000 }),
|
||||
session({ id: 'turn-old', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 }),
|
||||
],
|
||||
cases: CASES,
|
||||
sessionOrder: ['quiet-old', 'turn-young', 'quiet-new', 'turn-old'],
|
||||
});
|
||||
|
||||
expect(model.current.map((r: any) => r.id)).toEqual(['turn-old', 'turn-young', 'quiet-new', 'quiet-old']);
|
||||
});
|
||||
|
||||
it('puts the longest-blocked session at the top of NEEDS YOU', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
session({ id: 'just-asked', lastActivityAt: 9_000 }),
|
||||
session({ id: 'starving', lastActivityAt: 1_000 }),
|
||||
],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([
|
||||
['just-asked', new Set(['permission_prompt'])],
|
||||
['starving', new Set(['permission_prompt'])],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['starving', 'just-asked']);
|
||||
});
|
||||
|
||||
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
@@ -238,15 +277,28 @@ describe('mobile overview model', () => {
|
||||
expect(rows.i.createdAt).toBe(now - 7200_000);
|
||||
});
|
||||
|
||||
it('leaves the stamp off rather than inventing an anchor', () => {
|
||||
it('falls back to the sort anchor for a working row with no submit stamp', () => {
|
||||
// A session that has never submitted has no turn start to measure from, but
|
||||
// `sessionActivityAnchor` still RANKS it by lastActivityAt. The stamp must
|
||||
// show that same number rather than nothing: a row sorted by a value it
|
||||
// does not display reads as randomly placed.
|
||||
const now = Date.now();
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
// A session that has never submitted has no turn start to measure from.
|
||||
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: Date.now() })],
|
||||
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: now })],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(model.current[0].since).toEqual({ key: 'working', at: now });
|
||||
expect(model.current[0].createdAt).toBe(0);
|
||||
});
|
||||
|
||||
it('still leaves the stamp off when there is no anchor at all', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'w', status: 'busy' })],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(model.current[0].since).toBeNull();
|
||||
expect(model.current[0].createdAt).toBe(0);
|
||||
});
|
||||
|
||||
it('formats a moment as "ago" and a span as a bare duration', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createMockRouteContext(options?: {
|
||||
sessionId?: string;
|
||||
agentSkillEnabled?: boolean;
|
||||
claudeVoiceEnabled?: boolean;
|
||||
workspaceHooksEnabled?: boolean;
|
||||
}) {
|
||||
const sessionId = options?.sessionId ?? 'test-session-1';
|
||||
const session = createMockSession(sessionId);
|
||||
@@ -96,6 +97,9 @@ export function createMockRouteContext(options?: {
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
||||
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
|
||||
// Writes land in the test's temp working dir, never in a real repo.
|
||||
getWorkspaceHooksEnabled: vi.fn(async () => options?.workspaceHooksEnabled ?? true),
|
||||
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||
getLightSessionsState: vi.fn(() => {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview Response-viewer file-path linkifier (`CodemanApp._linkifyFilePaths`).
|
||||
*
|
||||
* The viewer renders markdown, so a path an agent wrote — "wrote the chart to
|
||||
* /tmp/.../chart.png" — arrived as inert text: the terminal's link provider
|
||||
* never sees the chat, and the file it just produced was a copy-paste away
|
||||
* instead of a click. The linkifier wraps those paths in an anchor the click
|
||||
* delegate hands to the file-preview overlay.
|
||||
*
|
||||
* Two properties matter more than the linking itself and are pinned here:
|
||||
*
|
||||
* 1. **The text is untouched.** Anchors are built from TEXT NODES with DOM
|
||||
* APIs, never by rebuilding already-sanitized markup as a string, so the
|
||||
* message reads identically and "copy code" still yields exactly what the
|
||||
* agent printed.
|
||||
* 2. **Model output cannot become markup.** The source is model text; a
|
||||
* path-shaped string carrying HTML must stay text.
|
||||
*
|
||||
* Loaded via `vm` with a jsdom document injected (same technique as
|
||||
* connection-indicator.test.ts — no per-file jsdom environment, which would
|
||||
* externalize node:fs under vite).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const { document, NodeFilter } = dom.window;
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: vi.fn(),
|
||||
document,
|
||||
NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
/** Render `html` into a detached .rv-text div and run the linkifier over it. */
|
||||
function linkify(html: string): HTMLElement {
|
||||
const app = Object.create(CodemanApp.prototype) as { _linkifyFilePaths(root: unknown): void };
|
||||
const root = document.createElement('div');
|
||||
root.className = 'rv-text';
|
||||
root.innerHTML = html;
|
||||
app._linkifyFilePaths(root);
|
||||
return root as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path'));
|
||||
|
||||
describe('response viewer file-path linkifier', () => {
|
||||
it('links an absolute path written as prose', () => {
|
||||
const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png';
|
||||
const root = linkify(`<p>Saved the capture to ${path} — have a look.</p>`);
|
||||
|
||||
const links = paths(root);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].getAttribute('data-path')).toBe(path);
|
||||
expect(links[0].textContent).toBe(path);
|
||||
expect(root.textContent).toBe(`Saved the capture to ${path} — have a look.`);
|
||||
});
|
||||
|
||||
it('links a path inside inline code, which is how agents usually write one', () => {
|
||||
const root = linkify('<p>See <code>/home/a/out/report.pdf</code> for the numbers.</p>');
|
||||
|
||||
const links = paths(root);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].getAttribute('data-path')).toBe('/home/a/out/report.pdf');
|
||||
// Still inside the <code> span — the code styling is not lost.
|
||||
expect(links[0].closest('code')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('links every path in one text node and preserves the text between them', () => {
|
||||
const root = linkify('<p>Compare /tmp/before.png with /tmp/after.png please</p>');
|
||||
|
||||
expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(['/tmp/before.png', '/tmp/after.png']);
|
||||
expect(root.textContent).toBe('Compare /tmp/before.png with /tmp/after.png please');
|
||||
});
|
||||
|
||||
it('never re-cuts text already inside an anchor', () => {
|
||||
// marked autolinks URLs; a path-looking tail inside one must stay whole, and
|
||||
// a nested <a> is invalid markup that would swallow the outer link's click.
|
||||
// ⚠️ The URL's tail MUST be a string the pattern matches on its own
|
||||
// (`/tmp/...` here): with an unmatchable tail this test passes with the
|
||||
// inside-anchor guard deleted, i.e. it pins nothing.
|
||||
const root = linkify('<p><a href="https://example.com/tmp/shot.png">https://example.com/tmp/shot.png</a></p>');
|
||||
|
||||
expect(paths(root)).toHaveLength(0);
|
||||
expect(root.querySelectorAll('a')).toHaveLength(1);
|
||||
expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png');
|
||||
});
|
||||
|
||||
it('leaves text with no path untouched', () => {
|
||||
const root = linkify('<p>Ratio 3/4 on 2026/08/16, see src/app.ts</p>');
|
||||
|
||||
expect(paths(root)).toHaveLength(0);
|
||||
expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts');
|
||||
});
|
||||
|
||||
it('cannot turn model text into markup', () => {
|
||||
// The anchor is built with createElement + textContent, so even a
|
||||
// path-shaped payload stays text. (`<` also ends a match, so the linkifier
|
||||
// never spans into it in the first place.)
|
||||
const root = linkify('<p>/tmp/x.png<img src=x onerror=alert(1)>.png</p>');
|
||||
|
||||
expect(root.querySelector('img')).toBeNull();
|
||||
expect(root.textContent).toContain('<img src=x onerror=alert(1)>.png');
|
||||
for (const link of paths(root)) {
|
||||
expect(link.innerHTML).toBe(link.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
it('is wired into message rendering and the click delegate', () => {
|
||||
// The linkifier is only reachable through these two call sites; losing
|
||||
// either leaves inert paths (no linkify) or dead links (no handler).
|
||||
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
||||
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
registerExternalAttachment,
|
||||
type AttachmentRecord,
|
||||
} from '../../src/attachment-registry.js';
|
||||
import { SseEvent } from '../../src/web/sse-events.js';
|
||||
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||
@@ -355,4 +356,250 @@ describe('file-routes attachment path guard (COD-53)', () => {
|
||||
attachmentRegistry.clearSession('test-session-mlc');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Media (click-to-preview parity with the workspace preview) =====
|
||||
// A video an agent writes inside the workspace plays with a working scrub
|
||||
// bar; the same file in /tmp used to be refused as an unsupported type. Both
|
||||
// now go through the same extension sets, and the raw route has to answer
|
||||
// with a real media Content-Type and a range, or the player renders and then
|
||||
// does nothing.
|
||||
describe('media attachments', () => {
|
||||
it('registers a video and serves it as seekable video/mp4', async () => {
|
||||
const content = Buffer.from('MP4DATA-0123456789');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(4, 10)]) as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/captures/demo.mp4', notify: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.attachmentType).toBe('video');
|
||||
|
||||
const rawRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
|
||||
headers: { range: 'bytes=4-9' },
|
||||
});
|
||||
expect(rawRes.statusCode).toBe(206);
|
||||
expect(rawRes.headers['content-type']).toBe('video/mp4');
|
||||
expect(rawRes.headers['content-range']).toBe(`bytes 4-9/${content.length}`);
|
||||
expect(rawRes.headers['accept-ranges']).toBe('bytes');
|
||||
});
|
||||
|
||||
it('registers audio with an audio type and its real MIME', async () => {
|
||||
const content = Buffer.from('ID3AUDIO');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/captures/take.mp3', notify: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.attachmentType).toBe('audio');
|
||||
|
||||
const rawRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
|
||||
});
|
||||
expect(rawRes.statusCode).toBe(200);
|
||||
expect(rawRes.headers['content-type']).toBe('audio/mpeg');
|
||||
});
|
||||
|
||||
it('answers no thumbnail for media instead of spawning a converter', async () => {
|
||||
// generateFirstPageThumbnail has no media branch; the card falls back to
|
||||
// its type label. This pins that the route reports that cleanly.
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/captures/clip.webm', notify: false },
|
||||
});
|
||||
const { attachmentId } = JSON.parse(res.body).data;
|
||||
|
||||
const thumbRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/thumbnail`,
|
||||
});
|
||||
expect(thumbRes.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('still refuses media in a blocked tree', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/root/private/recording.mp4', notify: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Text family (code, config and logs outside the workspace) =====
|
||||
// The agent in the session can already `cat` these, so refusing the click
|
||||
// bought no confidentiality. The gate that matters is the path guard, which
|
||||
// still runs, and markup must not become executable just because it is now
|
||||
// readable.
|
||||
describe('text attachments', () => {
|
||||
it.each([
|
||||
['/tmp/run.log', 'log'],
|
||||
['/tmp/data.json', 'json'],
|
||||
['/tmp/conf/app.yaml', 'yaml'],
|
||||
['/tmp/src/index.ts', 'ts'],
|
||||
['/tmp/export.csv', 'csv'],
|
||||
])('registers %s as a text attachment', async (path, extension) => {
|
||||
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path, notify: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.extension).toBe(extension);
|
||||
expect(body.data.attachmentType).toBe('text');
|
||||
});
|
||||
|
||||
it('serves a text file with no dedicated MIME as inert text/plain', async () => {
|
||||
const content = Buffer.from('boot ok\nstarted\n');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
const reg = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/run.log', notify: false },
|
||||
});
|
||||
const rawRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
|
||||
});
|
||||
|
||||
expect(rawRes.statusCode).toBe(200);
|
||||
expect(rawRes.headers['content-type']).toBe('text/plain; charset=utf-8');
|
||||
expect(rawRes.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('keeps HTML download-only so readable never means executable', async () => {
|
||||
// Serving markup with a renderable type on our own origin is stored XSS.
|
||||
// The preview reads it through fetch(), which ignores the disposition, so
|
||||
// a clicked .html still shows its source.
|
||||
const content = Buffer.from('<script>alert(1)</script>');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
const reg = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/report.html', notify: false },
|
||||
});
|
||||
const rawRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
|
||||
});
|
||||
|
||||
expect(rawRes.headers['content-type']).toBe('application/octet-stream');
|
||||
expect(String(rawRes.headers['content-disposition'])).toContain('attachment');
|
||||
});
|
||||
|
||||
it('answers a byte range for text so a huge log is a partial read', async () => {
|
||||
const content = Buffer.from('0123456789abcdef');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(0, 8)]) as never);
|
||||
|
||||
const reg = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/big.log', notify: false },
|
||||
});
|
||||
const rawRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
|
||||
headers: { range: 'bytes=0-7' },
|
||||
});
|
||||
|
||||
expect(rawRes.statusCode).toBe(206);
|
||||
expect(rawRes.headers['content-range']).toBe(`bytes 0-7/${content.length}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/home/someone/.config/gh/hosts.yml', 'forge token'],
|
||||
['/home/someone/project/.env.json', 'dotenv'],
|
||||
['/home/someone/.codeman/state.json', 'codeman state (can hold envOverrides secrets)'],
|
||||
['/home/someone/deploy/credentials.yaml', 'generic credentials'],
|
||||
['/etc/codeman/dump.log', 'blocked tree'],
|
||||
])('still refuses %s (%s) now that text is servable', async (path) => {
|
||||
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path, notify: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('still refuses a type outside the family', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: '/tmp/drawing.svg', notify: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/unsupported/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Quiet registration (click-to-preview) =====
|
||||
// The file-preview overlay registers a clicked out-of-workspace path to mint
|
||||
// an id it can render by. It is already putting the file on screen, so the
|
||||
// usual attachment card + unread badge would announce what the user is
|
||||
// looking at. `notify: false` suppresses ONLY the broadcast — the guard, the
|
||||
// registry entry and the by-id routes are identical either way.
|
||||
describe('quiet registration', () => {
|
||||
const outside = '/tmp/claude-1000/scratchpad/probe-run-native.png';
|
||||
|
||||
it('broadcasts by default, so the CLI and publish paths keep their card', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 128, isFile: () => true, mtimeMs: 5 } as never);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: outside },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(SseEvent.AttachmentDetected, expect.anything());
|
||||
});
|
||||
|
||||
it('registers and serves a clicked path without broadcasting when notify is false', async () => {
|
||||
const content = Buffer.from('PNGDATA');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||
payload: { path: outside, notify: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.fileName).toBe('probe-run-native.png');
|
||||
expect(harness.ctx.broadcast).not.toHaveBeenCalled();
|
||||
|
||||
// The preview renders from this route, so the id has to be live.
|
||||
const rawRes = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
|
||||
});
|
||||
expect(rawRes.statusCode).toBe(200);
|
||||
expect(rawRes.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @fileoverview Hooks are installed into the workspace a claude session starts in.
|
||||
*
|
||||
* Regression cover for the 2026-08-15 report: a session in a LINKED case (the user's
|
||||
* own repo, where most sessions live) ran with no hooks block at all, because
|
||||
* `writeHooksConfig` only fires when Codeman CREATES a case directory and the old
|
||||
* self-heal call deliberately never ADDED one. The visible symptom was an
|
||||
* AskUserQuestion dialog blocking the pane while the tab and the phone overview both
|
||||
* showed a calm `idle` — no hook event, so no pending-hook state, so no alert.
|
||||
*
|
||||
* Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call.
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { generateHooksConfig } from '../../src/hooks-config.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
|
||||
interface HooksFile {
|
||||
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
|
||||
permissions?: unknown;
|
||||
model?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A faithful PRE-SECRET Codeman hooks block (what a case created before COD-54
|
||||
* contains): it targets /api/hook-event, so it is recognisably ours, but carries
|
||||
* no X-Codeman-Hook-Secret header and no -k. Used to prove the self-heal still
|
||||
* runs with the setting OFF.
|
||||
*/
|
||||
function staleCodemanHooks() {
|
||||
return {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
|
||||
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
|
||||
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
|
||||
timeout: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /api/sessions workspace hooks', () => {
|
||||
let app: FastifyInstance;
|
||||
let workingDir: string;
|
||||
|
||||
const settingsPath = () => join(workingDir, '.claude', 'settings.local.json');
|
||||
const readSettings = async (): Promise<HooksFile> => JSON.parse(await readFile(settingsPath(), 'utf-8'));
|
||||
|
||||
const createSession = (payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: '/api/sessions', payload });
|
||||
|
||||
/** Rebuild the app with the `workspaceHooksEnabled` gate in a given position. */
|
||||
const useApp = async (workspaceHooksEnabled: boolean) => {
|
||||
await app?.close();
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
registerSessionRoutes(app, createMockRouteContext({ workspaceHooksEnabled }));
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDir = await mkdtemp(join(tmpdir(), 'codeman-workspace-hooks-'));
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
registerSessionRoutes(app, createMockRouteContext());
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('installs hooks in a workspace that has none (the linked-case bug)', async () => {
|
||||
const res = await createSession({ name: 'hooks-fresh', mode: 'claude', workingDir });
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const settings = await readSettings();
|
||||
const matchers = (settings.hooks?.Notification ?? []).map((entry) => entry.matcher);
|
||||
// permission_prompt is the one an AskUserQuestion dialog raises; the
|
||||
// elicitation pair is what CLOSES the resulting Approvals Inbox item.
|
||||
expect(matchers).toEqual(
|
||||
expect.arrayContaining([
|
||||
'idle_prompt',
|
||||
'permission_prompt',
|
||||
'elicitation_dialog',
|
||||
'elicitation_complete',
|
||||
'elicitation_response',
|
||||
])
|
||||
);
|
||||
expect(settings.hooks?.Stop?.length).toBeGreaterThan(0);
|
||||
|
||||
const serialized = JSON.stringify(settings.hooks);
|
||||
// The two shapes that have historically shipped dead hooks: no secret header
|
||||
// (401 once the gate went unconditional) and no -k (exit 60 on HTTPS installs).
|
||||
expect(serialized).toContain('X-Codeman-Hook-Secret');
|
||||
expect(serialized).toContain('curl -sk -X POST');
|
||||
});
|
||||
|
||||
it('merges into a user-owned settings file without disturbing it', async () => {
|
||||
await mkdir(join(workingDir, '.claude'), { recursive: true });
|
||||
const userHook = { matcher: 'Write', hooks: [{ type: 'command', command: './my-formatter.sh' }] };
|
||||
await writeFile(
|
||||
settingsPath(),
|
||||
JSON.stringify({ model: 'opus[1m]', permissions: { allow: ['Read'] }, hooks: { PostToolUse: [userHook] } })
|
||||
);
|
||||
|
||||
expect((await createSession({ name: 'hooks-merge', mode: 'claude', workingDir })).statusCode).toBe(200);
|
||||
|
||||
const settings = await readSettings();
|
||||
expect(settings.model).toBe('opus[1m]');
|
||||
expect(settings.permissions).toEqual({ allow: ['Read'] });
|
||||
expect(JSON.stringify(settings.hooks)).toContain('./my-formatter.sh');
|
||||
expect((settings.hooks?.Notification ?? []).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('leaves a non-claude session alone (only claude reads .claude hooks)', async () => {
|
||||
expect((await createSession({ name: 'hooks-shell', mode: 'shell', workingDir })).statusCode).toBe(200);
|
||||
expect(existsSync(settingsPath())).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the server cwd alone when workingDir is omitted', async () => {
|
||||
// workingDir falls back to process.cwd(), which is $HOME under installer-created
|
||||
// services — hooks must not materialize in ~/.claude/settings.local.json.
|
||||
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
|
||||
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||
|
||||
expect((await createSession({ name: 'hooks-no-dir', mode: 'claude' })).statusCode).toBe(200);
|
||||
|
||||
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
|
||||
// A claude-mode attachRemoteSession create overwrites workingDir with
|
||||
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
|
||||
// as a junk directory under the server cwd.
|
||||
await mkdir(getDataDir(), { recursive: true });
|
||||
await writeFile(
|
||||
join(getDataDir(), 'remote-hosts.json'),
|
||||
JSON.stringify([{ id: 'h1', label: 'box', host: '10.0.0.5', username: 'dev' }])
|
||||
);
|
||||
|
||||
const res = await createSession({
|
||||
name: 'hooks-remote',
|
||||
mode: 'claude',
|
||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(process.cwd(), 'dev@10.0.0.5:codeman-ssh-abc123'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a malformed settings file untouched rather than replacing it', async () => {
|
||||
await mkdir(join(workingDir, '.claude'), { recursive: true });
|
||||
await writeFile(settingsPath(), '{ not json');
|
||||
|
||||
expect((await createSession({ name: 'hooks-malformed', mode: 'claude', workingDir })).statusCode).toBe(200);
|
||||
expect(await readFile(settingsPath(), 'utf-8')).toBe('{ not json');
|
||||
});
|
||||
|
||||
it('adds nothing when workspaceHooksEnabled is OFF', async () => {
|
||||
await useApp(false);
|
||||
|
||||
expect((await createSession({ name: 'hooks-off', mode: 'claude', workingDir })).statusCode).toBe(200);
|
||||
expect(existsSync(settingsPath())).toBe(false);
|
||||
});
|
||||
|
||||
it('still heals a stale Codeman block when workspaceHooksEnabled is OFF', async () => {
|
||||
// The setting turns off ADDING hooks, not the COD-91 self-heal: a pre-secret
|
||||
// block 401s against the now-unconditional hook-secret gate, so a workspace that
|
||||
// already opted in must not be left with hooks that silently fail.
|
||||
await useApp(false);
|
||||
await mkdir(join(workingDir, '.claude'), { recursive: true });
|
||||
await writeFile(settingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() }));
|
||||
|
||||
expect((await createSession({ name: 'hooks-off-stale', mode: 'claude', workingDir })).statusCode).toBe(200);
|
||||
|
||||
const settings = await readSettings();
|
||||
expect(settings.model).toBe('opus');
|
||||
expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret');
|
||||
});
|
||||
|
||||
it('writes the hooks the generator produces, so the two cannot drift', async () => {
|
||||
expect((await createSession({ name: 'hooks-parity', mode: 'claude', workingDir })).statusCode).toBe(200);
|
||||
|
||||
const written = (await readSettings()).hooks ?? {};
|
||||
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,20 @@ describe('isSensitivePath', () => {
|
||||
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
|
||||
['codeman user table', `${HOME}/.codeman/users.json`],
|
||||
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
|
||||
// state.json persists SessionState.envOverrides, and the env allowlist
|
||||
// admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold
|
||||
// a live credential. Named once .json became previewable from outside the
|
||||
// workspace.
|
||||
['codeman state file', `${HOME}/.codeman/state.json`],
|
||||
['codeman state file on a named instance', `${HOME}/.codeman-beta/state.json`],
|
||||
['codeman state sibling (same payload)', `${HOME}/.codeman/state-inner.json`],
|
||||
// settings.json holds voiceSettings.apiKey by schema; push-keys.json holds
|
||||
// the VAPID PRIVATE key; intents.json is 0600 because captured prompts can
|
||||
// contain secrets and is deliberately kept out of /api/search.
|
||||
['codeman settings (Deepgram key)', `${HOME}/.codeman/settings.json`],
|
||||
['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`],
|
||||
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
|
||||
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
|
||||
];
|
||||
|
||||
it.each(blocked)('blocks the %s', (_label, path) => {
|
||||
@@ -88,6 +102,7 @@ describe('isSensitivePath', () => {
|
||||
// The publish skill and the review-card loop attach from these trees, so
|
||||
// only their named secret members are blocked, never the whole tree.
|
||||
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
|
||||
['a codeman lifecycle log', `${HOME}/.codeman/session-lifecycle.jsonl`],
|
||||
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
|
||||
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
|
||||
// isUnderTree-style separator awareness: a sibling name that merely starts
|
||||
|
||||
@@ -246,3 +246,45 @@ describe('Session interactive idle detection', () => {
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wire activity stamp across recovery', () => {
|
||||
// The stamp both home screens sort the quiet group on. Recovery restores the
|
||||
// previous run's value, and the settle window keeps the boot attach repaint
|
||||
// (ordinary PTY output, arriving within seconds of construction) from
|
||||
// restamping every session "now": measured live, a restart left 17 of 17
|
||||
// sessions with an identical lastActivityAt, which flattens the ordering to
|
||||
// tab order after every deploy.
|
||||
const OLD = 1_700_000_000_000;
|
||||
const restored = () =>
|
||||
new Session({ workingDir: '/tmp', mode: 'claude', lastActivityAt: OLD } as ConstructorParameters<
|
||||
typeof Session
|
||||
>[0]);
|
||||
|
||||
it('restores the previous-run stamp and holds it through attach-repaint output', () => {
|
||||
const session = restored();
|
||||
expect(session.lastActivityAt).toBe(OLD);
|
||||
(session as unknown as SessionInternals)._handleTerminalOutput('attach repaint bytes');
|
||||
expect(session.lastActivityAt).toBe(OLD);
|
||||
expect(session.toState().lastActivityAt).toBe(OLD);
|
||||
});
|
||||
|
||||
it('a real action writes through the settle window', () => {
|
||||
const session = restored();
|
||||
session.assignTask('t1');
|
||||
expect(session.lastActivityAt).toBeGreaterThan(OLD);
|
||||
});
|
||||
|
||||
it('output after the window moves the stamp normally', () => {
|
||||
const session = restored();
|
||||
(session as unknown as { _wireActivitySettleUntil: number })._wireActivitySettleUntil = Date.now() - 1;
|
||||
(session as unknown as SessionInternals)._handleTerminalOutput('real output');
|
||||
expect(session.lastActivityAt).toBeGreaterThan(OLD);
|
||||
});
|
||||
|
||||
it('a fresh session has no window: first output stamps immediately', () => {
|
||||
const before = Date.now();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
(session as unknown as SessionInternals)._handleTerminalOutput('x');
|
||||
expect(session.lastActivityAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Port: none (pure comparator — no browser, no server).
|
||||
//
|
||||
// `CodemanSessionOrder` (src/web/public/constants.js) is the single row order
|
||||
// behind both home screens: the phone overview and the desktop tab rail. It is
|
||||
// the one place the two surfaces can disagree about which session you should
|
||||
// look at next, which is why it is pure and pinned here rather than living
|
||||
// inside either renderer.
|
||||
//
|
||||
// The rule it encodes, and the thing worth protecting: the tiebreak FLIPS
|
||||
// direction halfway down the list. For a state a session is still in, older is
|
||||
// more urgent (blocked longest, running longest). For a state it has stopped
|
||||
// in, newer is more relevant (just finished beats abandoned yesterday).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
state: string;
|
||||
lastActivityAt?: number;
|
||||
lastSubmitAt?: number;
|
||||
orderIndex?: number;
|
||||
};
|
||||
|
||||
function loadOrderHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (
|
||||
context.window as {
|
||||
CodemanSessionOrder: {
|
||||
RANK: Record<string, number>;
|
||||
anchor: (row: Row) => number;
|
||||
compare: (a: Row, b: Row) => number;
|
||||
sort: (rows: Row[]) => Row[];
|
||||
};
|
||||
}
|
||||
).CodemanSessionOrder;
|
||||
}
|
||||
|
||||
const order = loadOrderHelper();
|
||||
const ids = (rows: Row[]) => order.sort(rows).map((r) => r.id);
|
||||
|
||||
describe('session overview order: state ranking', () => {
|
||||
it('puts everything blocked on a human above everything else', () => {
|
||||
// Red question, then a hard error, then the yellow "waiting for input"
|
||||
// prompt, then work, then whatever has stopped.
|
||||
const rows: Row[] = [
|
||||
{ id: 'done', state: 'done' },
|
||||
{ id: 'idle', state: 'idle' },
|
||||
{ id: 'working', state: 'working' },
|
||||
{ id: 'waiting', state: 'waiting' },
|
||||
{ id: 'error', state: 'error' },
|
||||
{ id: 'needs', state: 'needs' },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['needs', 'error', 'waiting', 'working', 'idle', 'done']);
|
||||
});
|
||||
|
||||
it('sorts an unknown state last instead of dropping it or crashing', () => {
|
||||
// A state added to one renderer and not to the rank map must still render,
|
||||
// just at the bottom — a missing row is a worse failure than a misplaced one.
|
||||
const rows: Row[] = [
|
||||
{ id: 'mystery', state: 'quantum' },
|
||||
{ id: 'done', state: 'done' },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['done', 'mystery']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session overview order: in-progress states sort oldest first', () => {
|
||||
it('ranks the longest-running turn above a turn that just started', () => {
|
||||
const rows: Row[] = [
|
||||
{ id: 'young', state: 'working', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
||||
{ id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['old', 'young']);
|
||||
});
|
||||
|
||||
it('measures a running turn from the last Enter, not the last repaint', () => {
|
||||
// A working pane repaints about once a second, so last-activity is always
|
||||
// "now" and would rank every running turn identically.
|
||||
expect(order.anchor({ id: 'w', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(1_000);
|
||||
expect(order.anchor({ id: 'i', state: 'idle', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(999_000);
|
||||
});
|
||||
|
||||
it('falls back to last activity for a working pane that never submitted', () => {
|
||||
// Spawned with its prompt on the command line, or an external CLI whose
|
||||
// Enter never went through Codeman. Its fallback stamp is ~now, so it sits
|
||||
// at the SHORT end of the running group rather than falsely leading it.
|
||||
const rows: Row[] = [
|
||||
{ id: 'no-submit', state: 'working', lastActivityAt: 10_000 },
|
||||
{ id: 'submitted', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['submitted', 'no-submit']);
|
||||
});
|
||||
|
||||
it('ranks the longest-blocked session above one that just asked', () => {
|
||||
const rows: Row[] = [
|
||||
{ id: 'just-asked', state: 'needs', lastActivityAt: 9_000 },
|
||||
{ id: 'starving', state: 'needs', lastActivityAt: 1_000 },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['starving', 'just-asked']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session overview order: stopped states sort newest first', () => {
|
||||
it('puts the session that just went quiet above one idle since yesterday', () => {
|
||||
const rows: Row[] = [
|
||||
{ id: 'yesterday', state: 'idle', lastActivityAt: 1_000 },
|
||||
{ id: 'just-now', state: 'idle', lastActivityAt: 9_000 },
|
||||
{ id: 'this-morning', state: 'idle', lastActivityAt: 5_000 },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['just-now', 'this-morning', 'yesterday']);
|
||||
});
|
||||
|
||||
it('applies the same recency rule to finished sessions', () => {
|
||||
const rows: Row[] = [
|
||||
{ id: 'old-exit', state: 'done', lastActivityAt: 1_000 },
|
||||
{ id: 'fresh-exit', state: 'done', lastActivityAt: 9_000 },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['fresh-exit', 'old-exit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session overview order: tiebreaks', () => {
|
||||
it('falls back to the tab order when two rows share a stamp', () => {
|
||||
const rows: Row[] = [
|
||||
{ id: 'third', state: 'idle', lastActivityAt: 5_000, orderIndex: 2 },
|
||||
{ id: 'first', state: 'idle', lastActivityAt: 5_000, orderIndex: 0 },
|
||||
];
|
||||
expect(ids(rows)).toEqual(['first', 'third']);
|
||||
});
|
||||
|
||||
it('sorts an unstamped row last within its state, never first', () => {
|
||||
// 0 is "we have no stamp", not "the epoch": treating it as a timestamp
|
||||
// would park a brand-new session at the head of the oldest-first groups.
|
||||
expect(
|
||||
ids([
|
||||
{ id: 'none', state: 'idle', orderIndex: 0 },
|
||||
{ id: 'stamped', state: 'idle', lastActivityAt: 1_000, orderIndex: 1 },
|
||||
])
|
||||
).toEqual(['stamped', 'none']);
|
||||
expect(
|
||||
ids([
|
||||
{ id: 'none', state: 'working', orderIndex: 0 },
|
||||
{ id: 'stamped', state: 'working', lastSubmitAt: 1_000, orderIndex: 1 },
|
||||
])
|
||||
).toEqual(['stamped', 'none']);
|
||||
});
|
||||
|
||||
it('is deterministic: two unstamped rows keep tab order in both directions', () => {
|
||||
const a: Row = { id: 'a', state: 'idle', orderIndex: 0 };
|
||||
const b: Row = { id: 'b', state: 'idle', orderIndex: 1 };
|
||||
expect(order.compare(a, b)).toBeLessThan(0);
|
||||
expect(order.compare(b, a)).toBeGreaterThan(0);
|
||||
expect(order.compare(a, a)).toBe(0);
|
||||
});
|
||||
|
||||
it('copies rather than sorting the caller array in place', () => {
|
||||
// Both renderers hand it a filtered slice of a shared row array; mutating
|
||||
// that would reorder the other surface's list as a side effect.
|
||||
const rows: Row[] = [
|
||||
{ id: 'b', state: 'idle', lastActivityAt: 1_000 },
|
||||
{ id: 'a', state: 'idle', lastActivityAt: 9_000 },
|
||||
];
|
||||
order.sort(rows);
|
||||
expect(rows.map((r) => r.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('survives junk input rather than throwing inside a render', () => {
|
||||
expect(order.sort(undefined as unknown as Row[])).toEqual([]);
|
||||
expect(order.anchor({} as Row)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user