mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4b940cef | ||
|
|
da999b130e | ||
|
|
cbc54fc98d | ||
|
|
4e2c1b9989 | ||
|
|
869a507482 | ||
|
|
854bcb99aa | ||
|
|
9ee6bf113b | ||
|
|
66d4c483c7 | ||
|
|
ff13234b3d | ||
|
|
0af80b417c |
@@ -0,0 +1,80 @@
|
||||
# Contributing to Codeman
|
||||
|
||||
Thanks for wanting to help! Codeman is a small project with a fast loop: issues usually get a response within a day, good PRs get reviewed quickly, and every release credits its contributors and bug reporters by name in the release notes. This guide gets you from clone to merged PR without stepping on the traps.
|
||||
|
||||
## The short version
|
||||
|
||||
1. **Bugs**: open an issue with your OS, install method (installer / npm / git clone), browser, and which CLI + version the session was running.
|
||||
2. **Questions and ideas**: use [Discussions](https://github.com/Ark0N/Codeman/discussions), not issues.
|
||||
3. **Small fixes** (docs, typos, a new skin, a translation): just send the PR.
|
||||
4. **Anything bigger**: open an issue or Discussion first and get a nod before building. Codeman has strong architectural invariants, and a design chat up front is what turns a big idea into a merged PR instead of a stalled one. This flow works: features like Clone Repo (#236) went idea, then design discussion, then review, then shipped.
|
||||
5. **Security issues**: never a public issue. See [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Dev setup
|
||||
|
||||
Requirements: Node.js 22+ (see `.nvmrc`), tmux, and at least one supported agent CLI on your PATH (Claude Code is the primary one).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Ark0N/Codeman.git
|
||||
cd Codeman
|
||||
npm install # postinstall builds the vendored xterm addon bundles
|
||||
npm run dev # dev server on http://localhost:3000
|
||||
```
|
||||
|
||||
The frontend is plain JS served from `src/web/public/` with no bundler in dev: edit a `.js`/`.css` file and reload the page. The one exception is `index.html`, which is read once at server start, so markup changes need a server restart.
|
||||
|
||||
## Before you push
|
||||
|
||||
CI runs all of these, so save yourself a round trip:
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
npm test -- test/<file>.test.ts # one file (the normal way)
|
||||
npm run test:ci # the full CI sweep
|
||||
```
|
||||
|
||||
**Never run bare `npm test`.** The default config includes browser-driven Playwright suites that need a live server, Chromium, and environment-specific baselines; they will hang or fail on a normal machine. `test:ci` is the honest "run everything" command, it is exactly what CI runs.
|
||||
|
||||
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
|
||||
|
||||
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||
|
||||
## Finding your way around
|
||||
|
||||
- Every source file starts with a `@fileoverview` JSDoc block. Read it before diving into the file, it is the map.
|
||||
- [`CLAUDE.md`](../CLAUDE.md) at the repo root is the densest architecture primer in the repo. It is written for AI coding agents, but the invariants and gotchas in it apply to humans exactly the same, and most review feedback on PRs traces back to something already written there.
|
||||
- Deep mechanisms and the history behind each rule live in [`docs/architecture-invariants.md`](../docs/architecture-invariants.md).
|
||||
- Third-party extension surfaces are documented in [`docs/extending-codeman.md`](../docs/extending-codeman.md).
|
||||
|
||||
## Great first contributions
|
||||
|
||||
These are well-fenced areas where a first PR is genuinely easy to get right:
|
||||
|
||||
- **A new theme skin.** A skin is four things kept in sync: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist and the settings picker (both in `index.html`). `test/skin-themes.test.ts` statically checks the sync, so if the test passes, your skin works.
|
||||
- **A new language.** `src/web/public/i18n.js` is dependency-free, English is the canonical source, and `zh-CN` is a complete example to copy. Add your language's entries and register it in `SUPPORTED_LANGUAGES`.
|
||||
- **Docs.** If you got stuck on something and then figured it out, the sentence that would have unstuck you is a PR.
|
||||
- Anything labeled [`good first issue`](https://github.com/Ark0N/Codeman/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
Bigger extension points worth discussing first: new CLI backends (the pluggable resolver pattern has absorbed six CLIs so far; `docs/extending-codeman.md` and `docs/opencode-integration.md` show the shape), and real-device testing reports, especially mobile, which always find things emulation cannot.
|
||||
|
||||
## PR expectations
|
||||
|
||||
- **One change per PR.** Small and focused reviews fast; a grab-bag stalls.
|
||||
- Target the `master` branch.
|
||||
- **Keep your branch mergeable.** A PR with conflicts silently gets no CI runs at all (GitHub quirk), so rebase or merge master when conflicts appear.
|
||||
- Include or update tests when you change behavior. Route handlers have a lightweight pattern in `test/routes/` using `app.inject()` (no live server needed).
|
||||
- Formatting is Prettier with a deliberately narrow scope (`npm run format`), several frontend files are hand-formatted on purpose and excluded via `.prettierignore`. Don't "fix" a file by adding it back into Prettier's scope.
|
||||
- Don't bump versions or touch `CHANGELOG.md`; releases are handled by the maintainer via changesets after merge.
|
||||
- AI-assisted contributions are welcome (much of Codeman is built that way), with one condition: you must understand what you're submitting and have actually run it. "The model said it works" is not a test.
|
||||
|
||||
## Conduct
|
||||
|
||||
Be kind, be direct, assume good faith. Report unacceptable behavior privately via the contact in [SECURITY.md](SECURITY.md).
|
||||
@@ -1,5 +1,17 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.18.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Faster agent-skill workers, retuned multi-color lineage arcs, a per-tab pop-out option, reliable tab alerts, and the community launch.
|
||||
- Agent skill: SKILL.md now forbids the standalone preamble check and the pre-spawn reconnaissance turns that were costing whole model turns; the same two-worker spawn measured at 28.6s end to end now runs 20.2s cold and 12.8s warm, with the spawn machinery itself unchanged.
|
||||
- Session lineage lines: arcs now hang from the tab strip's bottom edge (dip cap 104px to 64px, no stacked row offsets), fixing the deep bow on wrapped tab strips and keeping same-row arcs off the second row's tab labels; each spawned worker's arc gets its own color (skin blue first, then matrix green, pink, violet, red, turquoise, orange), assigned per child and stable across re-renders.
|
||||
- Session Options > Session: new "Pop-out button on this tab" per-tab override on top of the general App Settings toggle (per-device).
|
||||
- Tab alerts: pending permission/question alerts now survive page reloads regardless of the Approvals Inbox setting (the alert state machine seeds from the server-side approval store on every load), stay visible on the selected tab until the prompt is actually resolved (the alert paints on a ::before overlay the active tab's styling cannot bury), and render as a steady red/yellow ring with glow and a colored status dot instead of a blink that spent half of every cycle looking like a normal tab. The README carries a live capture of the new alerts.
|
||||
- Community launch: README Community section, .github/CONTRIBUTING.md (dev setup, test safety, great first contributions, PR expectations), and GitHub Discussions.
|
||||
- docs: worker warm-pool design sketch with the measured baselines.
|
||||
|
||||
## 1.18.3
|
||||
|
||||
### 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.3 (must match `package.json`)
|
||||
**Version**: 1.18.4 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -204,13 +204,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting; the dip is also clamped at 104px rather than 44, since a skill worker lands at the END of the strip where the old cap flattened the arc into a straight thread. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. Colors cycle per CHILD in first-seen order from `CodemanLineage.COLORS` (first entry empty = the skin-tuned `--session-blue`; the rest vivid fixed hexes), set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||
|
||||
**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`.
|
||||
|
||||
**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` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`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`.
|
||||
**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`.
|
||||
|
||||
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.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`
|
||||
@@ -296,7 +298,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).
|
||||
|
||||
|
||||
@@ -406,6 +406,14 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||
|
||||
### Tab Alerts
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
||||
</p>
|
||||
|
||||
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||
|
||||
### Notifications
|
||||
|
||||
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
||||
@@ -1035,6 +1043,12 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, setup help, and ideas live in [GitHub Discussions](https://github.com/Ark0N/Codeman/discussions): the [Q&A section](https://github.com/Ark0N/Codeman/discussions/categories/q-a) answers the most common ones (phone access, overnight runs, updating), and the roadmap gets decided in [Ideas](https://github.com/Ark0N/Codeman/discussions/categories/ideas). Bugs go to [issues](https://github.com/Ark0N/Codeman/issues); reports usually get a response within a day, and every release credits its reporters and contributors by name. Want to contribute? [CONTRIBUTING.md](.github/CONTRIBUTING.md) has the map: skins, translations, and docs make great first PRs, and bigger features start life as a Discussion. And if you're proud of your rig, post it in [Show and tell](https://github.com/Ark0N/Codeman/discussions/300).
|
||||
|
||||
---
|
||||
|
||||
## Codebase Quality
|
||||
|
||||
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,121 @@
|
||||
# Warm worker pool: sub-second claude worker spawns
|
||||
|
||||
Design sketch. Status: **proposed**, not started. Opt-in (`workerPoolSize`, default 0 = off); a user who touches nothing sees no change at all.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem and numbers
|
||||
|
||||
Measured against prod 1.18.3 on 2026-08-15, AFTER the SKILL.md fast-path hardening
|
||||
(no recon turns), on the identical "spawn two codeman workers" prompt:
|
||||
|
||||
- **Cold orchestrator** (fresh session, skill loaded from disk): **20.2 s** prompt to
|
||||
final report. Breakdown: 3.9 s Skill-load turn, 6.4 s generating the one fused Bash
|
||||
call, **4.4 s spawn call**, 5.5 s summary. Tabs appeared at 10.5 s.
|
||||
- **Warm orchestrator** (skill already in context, no Skill turn): **12.8 s**, spawn
|
||||
call 6.0 s.
|
||||
- Inside the spawn call, session + tmux + case creation is cheap: the workers (and
|
||||
their tabs) appeared 0.2-1.7 s in, both siblings within ~350 ms of each other. The
|
||||
remaining **~4-5 s is claude CLI boot plus the composer-readiness wait**, paid again
|
||||
on every cold spawn. That slice is the pool's entire target.
|
||||
|
||||
The honest framing after the hardening: model turns dominate the skill flow (~16 of
|
||||
20 cold seconds) and no server feature can shrink those. The pool attacks the
|
||||
tool-side floor, and it has two distinct beneficiaries:
|
||||
|
||||
- **Skill/API orchestration**: the spawn call drops from ~4.4-6 s to ~1 s. Cold runs
|
||||
land ~16-17 s, warm ~8 s. Tab appearance barely moves for this consumer (it is
|
||||
model-turn-bound at ~10 s cold / ~4 s warm).
|
||||
- **The UI Run button and direct quick-start callers**: a click today waits the full
|
||||
boot + readiness before the worker can take a prompt; a pooled claim makes the tab
|
||||
appear and the worker READY sub-second. This is the most visible win, and it
|
||||
involves no skill at all.
|
||||
|
||||
Target: hand out an already-ready worker in **under 1 s**.
|
||||
|
||||
## 2. Shape
|
||||
|
||||
A new `src/worker-pool.ts` singleton service, following the `CronService` pattern: it **reuses the existing session layer** (`SessionManager` create + the normal spawn path) and never rebuilds tmux logic.
|
||||
|
||||
A pool member is a real claude `Session`, pre-spawned in a reserved scratch case (`~/codeman-cases/.pool-<n>`, created with the standard scaffold + hooks), already past readiness: composer drawn, hooks installed, preamble file seeded. It sits idle at the composer costing no tokens.
|
||||
|
||||
The claim happens **transparently inside `POST /api/quick-start`**: when a request is pool-eligible (§3) and a healthy member is available, quick-start returns that member instead of cold-spawning. The agent skill, the UI Run button, and every existing caller change **nothing**. Ineligible or pool-empty requests cold-spawn exactly as today, so the pool is only ever a fast path, never a behavior change.
|
||||
|
||||
## 3. Eligibility gate
|
||||
|
||||
Claim only when ALL of these hold; otherwise fall through to a cold spawn:
|
||||
|
||||
- `mode === 'claude'` (external CLIs have different readiness semantics and inject secrets via `tmux setenv` at spawn; out of scope).
|
||||
- No `envOverrides`, no `CLAUDE_CONFIG_DIR`, and `modelOverride`/`effort` unset or equal to what the pool member was spawned with. Env vars flow at spawn time and cannot be applied to a running CLI.
|
||||
- The requested case is **fresh** (does not exist yet). A linked case, an existing directory, a remote-SSH case, or a Docker case means the caller wants a specific workspace; pool members cannot provide one.
|
||||
- Single-user mode, or the requester owns the pool (v1 ships single-user only; §11).
|
||||
|
||||
## 4. What a claim does (~300 ms)
|
||||
|
||||
1. Pop a ready member (in-memory check-and-remove; Node's single thread makes this atomic, so two concurrent quick-starts cannot claim the same member).
|
||||
2. Health-probe it: `isPaneDead` (the existing ~750 ms-cached mux probe) plus one `capturePaneText` asserting a clean composer. A dead, limit-paused, or dirty member is recycled, and the claim tries the next member or falls through to cold spawn.
|
||||
3. Rename the session to the normal `w<n>-<case>` name, set `parentSessionId` via the existing `resolveParentSessionId()`, clear the pool flag, persist state.
|
||||
4. Emit `session_created` **now** (it was suppressed at warm-spawn time, §5). The tab appears here, sub-second after the request.
|
||||
5. Return the **pool case** as `casePath`/`workingDir` and do NOT create a directory under the requested name: an empty dir the worker's CLI does not run in is a trap (files written there are invisible to the worker at cwd), and the agent skill greps the RETURNED `casePath` for Codeman hooks before trusting the worker, so the response must point at the directory that really carries them.
|
||||
6. Kick a background refill (§6).
|
||||
|
||||
**The identity wrinkle, stated honestly:** the session id, `CODEMAN_SESSION_ID` inside the pane, the seeded preamble file, and the CLI's cwd are all fixed at warm-spawn and survive the claim unchanged. So a claimed worker's `workingDir` is the pool dir, not `~/codeman-cases/<requested-name>`; the requested name is a **label**. The API must report the truthful `workingDir`. Transcript projHash, response viewer, subagent windows, and Read My Mind all key off the real path and keep working precisely because we do not lie about it. This is acceptable for the dominant use (ephemeral skill workers that are deleted after answering) and is documented in the skill; a caller that needs the real case as cwd is by definition not pool-eligible.
|
||||
|
||||
**Verified skill compatibility (zero preamble changes).** Checked against the shipped 1.18.3 preamble: `spawn_worker`'s readiness probe (`_composer_up`) is a `wait-output` call with `from=buffer`, which scans output that already scrolled past before blocking, so a pooled member's long-since-drawn composer matches instantly instead of stranding a fresh-stream wait. The trust-dialog fallback never fires (members passed the dialog at warm time), and the hooks grep passes because the pool case carries the standard scaffold. Pooled and cold spawns are indistinguishable to the skill except in speed and the additive `pooled: true`.
|
||||
|
||||
## 5. Hiding pre-claim members
|
||||
|
||||
Pool members must be invisible until claimed or they read as ghost tabs. `Session.isPoolWorker` gates, at minimum:
|
||||
|
||||
- `GET /api/sessions` and `GET /api/sessions/unified` (and therefore the Cmd+K palette and the session-history-index snapshot that feeds `/api/search`).
|
||||
- `session_created` SSE at warm-spawn (deferred to claim time). All other per-session SSE for a hidden member is suppressed at the broadcast call sites it would reach.
|
||||
- Push notifications and the Approvals Inbox (a warm member showing a trust dialog must recycle, not notify).
|
||||
- The phone overview / home rail (both render from the session list, so the list filter covers them).
|
||||
- The lifecycle log records `pool_warm` / `pool_claim` events rather than user-visible session history.
|
||||
|
||||
`maxSessions` (50) **counts** pool members, and the pool refuses to warm within `poolSize + 2` of the cap so it can never starve real session creation.
|
||||
|
||||
## 6. Refill, TTL, drain
|
||||
|
||||
- **Refill** after each claim, debounced, at most one warm spawn in flight (a claim burst falls back to cold spawns rather than forking N CLIs at once; same reasoning as the document-conversion limiter).
|
||||
- **TTL ~30 min**: recycle members older than that so they cannot drift from settings, hooks config, or a self-updated CLI on disk.
|
||||
- **Drain and respawn** on: `claudeModel` change, hooks-config regeneration, self-update, and `workerPoolSize` changes. On server shutdown, kill pool sessions (they are stateless and ours). On boot, kill any leftover `.pool-*` tmux sessions found via `mux-sessions.json` rather than adopting them; adoption buys nothing for stateless members.
|
||||
|
||||
## 7. Failure modes
|
||||
|
||||
| Failure | Handling |
|
||||
| --- | --- |
|
||||
| Member died idle (PTY exit, crash) | Health probe at claim catches it; recycle + try next; PTY-exit breaker applies unchanged |
|
||||
| Member hit a usage limit while idle | `isLimitPaused` members are never handed out; recycle |
|
||||
| Composer dirty (stray keystrokes, dialog) | `capturePaneText` probe refuses it; recycle |
|
||||
| Claim race | Impossible by construction (synchronous in-memory pop) |
|
||||
| Warm spawn itself fails | Log, back off, retry on next refill tick; pool empty just means cold spawns |
|
||||
|
||||
## 8. Cost
|
||||
|
||||
Each warm member is one tmux session + one idle claude process (order 150-300 MB RSS; **measure before defaulting the size above 0**, including whether an idle CLI makes any background requests via its statusline refresh). Zero token cost while idle. Suggested starting size for users who opt in: 2.
|
||||
|
||||
## 9. Settings and API surface
|
||||
|
||||
- `workerPoolSize` (int, 0-4, default 0): **synced** setting in `SettingsUpdateSchema`. The watcher that resizes the pool on `PUT /api/settings` must resolve from `merged`, never the raw body (the partial-PUT gotcha in CLAUDE.md).
|
||||
- One internal status endpoint, `GET /api/worker-pool` (size, members' ages, claims served, fall-through count), for debugging. No new SSE events: the claim emits the existing `session_created`.
|
||||
- No new public API semantics: `/api/quick-start`'s contract is unchanged apart from a `pooled: true` field in the response data, which is additive.
|
||||
|
||||
## 10. Considered and rejected
|
||||
|
||||
- **Renaming the pool case dir to the requested name at claim.** Linux keeps the process cwd working across the rename (inode-based), but claude computed its transcript projHash from the old path string at boot, so transcripts, subagent windows, and the response viewer go blind, the exact failure mode the `CLAUDE_CONFIG_DIR` docs warn about. Truthful label semantics (§4) beat a clever rename.
|
||||
- **A new explicit claim endpoint.** Transparency inside quick-start means the skill, the UI, and every existing script get the speedup with zero changes; a new endpoint means new docs, new drift, and callers that must know the pool exists.
|
||||
- **Pooling external CLI modes.** Readiness there is output stabilization, secrets ride `tmux setenv` at spawn, and codex/pi composer semantics differ per CLI. Claude-only until someone measures a need.
|
||||
- **Returning quick-start at creation instead of readiness (no pool).** Would move tabs earlier on cold spawns too, but `sendwait` immediately after would then race the composer; readiness is what makes immediate tasking safe, and the pool makes the whole question moot for eligible spawns.
|
||||
|
||||
## 11. Phasing
|
||||
|
||||
1. **v1**: single-user, claude-only, fixed-size pool, transparent claim, status endpoint. Everything above.
|
||||
2. **v2**: per-owner pools for multi-user mode (pool members must carry an owner because ownership scoping is structural); possibly model-matched pools (one warm set per configured `claudeModel`).
|
||||
3. **Explicitly out**: warming linked/repo cases (spawning where the work is has no hooks and is the skill's documented costliest mistake; a warm pool must not make it faster to reach).
|
||||
|
||||
## 12. Testing
|
||||
|
||||
- Unit: pool manager logic pure and mock-driven (eligibility gate, TTL, refill debounce, drain triggers), `MockSession` from `test/mocks/`.
|
||||
- Route: `app.inject` on quick-start asserting claim vs cold-spawn per eligibility row in §3, plus the double-claim race (two concurrent injects, one pool member: exactly one `pooled: true`).
|
||||
- Live: re-run the pinned baselines against a warmed beta instance. Before (2026-08-15, prod 1.18.3, post-hardening): cold orchestrator **20.2 s** / warm **12.8 s** end to end, spawn call 4.4-6.0 s. Acceptance: spawn call under 1 s, cold ~16-17 s, warm ~8-9 s, and a UI Run click to a READY worker in under 1 s.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+25
-11
@@ -42,18 +42,22 @@ hundred-odd lines at the top of every call (a half-re-pasted preamble used to be
|
||||
single most likely way to break a run).
|
||||
|
||||
**Codeman seeds the preamble file for you** when it spawns a claude session (server
|
||||
1.18.3+), so the bootstrap is usually just loading it — the same two lines every later
|
||||
call starts with:
|
||||
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
|
||||
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; }
|
||||
```
|
||||
|
||||
If that passed, §0 is done: go straight to your job (§1's block opens with this same
|
||||
loader, so when §1 is the job you can simply start there). Only when it reports
|
||||
missing or stale, run the full block below once — and run it **verbatim**: paste it
|
||||
as-is, never re-type it, trim it, or "extract the parts you need". A hand-assembled
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
loader, so when §1 is the job, start there: the check rides the spawn call for free,
|
||||
and a standalone "preamble OK" call buys nothing while costing a full model turn
|
||||
(measured live: a lone check plus the deliberation around it added ~6 s to a 28 s
|
||||
two-worker run). §0 is done the moment any job call passes its opening check. Only
|
||||
when a call reports missing or stale, run the full block below once — and run it
|
||||
**verbatim**: paste it as-is, never re-type it, trim it, or "extract the parts you
|
||||
need". A hand-assembled
|
||||
preamble is the documented failure mode of this skill: one live run rebuilt it
|
||||
"minimally" and lost the `X-Codeman-Parent-Session` header (every worker spawned with
|
||||
no lineage arc in the web UI) and the fast-path functions (the spawn fell back to a
|
||||
@@ -273,14 +277,18 @@ plain-text 401: see §6 and [the symptom gallery](reference/endpoints.md#symptom
|
||||
block is the whole thing. Run it, report, and stop reading. §2 onward is for jobs this
|
||||
does not cover; you are not being careless by not reading them.**
|
||||
|
||||
Fill in the case names and the prompts. Everything below is `spawn_workers` /
|
||||
`sendwait` / `last_text` / `delete_session` from the §0 preamble, so there is nothing
|
||||
to assemble and no per-call body to hand-build.
|
||||
Fill in the case names and the prompts, then run it as your FIRST Bash call: no
|
||||
standalone preamble check before it (line one below IS that check), and no
|
||||
reconnaissance. `ls ~/codeman-cases` answers nothing this block needs: invented
|
||||
fresh names need no lookup, and `spawn_worker` refuses a name that already exists
|
||||
rather than silently reusing it. Everything below is `spawn_workers` / `sendwait` /
|
||||
`last_text` / `delete_session` from the §0 preamble, so there is nothing to assemble
|
||||
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; }
|
||||
N=(alpha beta) # one FRESH case name per worker
|
||||
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
|
||||
|
||||
@@ -307,10 +315,16 @@ done; rm -rf "$D"
|
||||
|
||||
Measured against a live 1.18.0 server: two cold workers spawned and ready in **6.3 s**,
|
||||
both turns dispatched and both answers read in **4.0 s** more. If your run takes minutes,
|
||||
the time went into deliberation, not the API. The three things that actually cost time:
|
||||
the time went into deliberation, not the API. The four things that actually cost time:
|
||||
|
||||
- **Spawning serially.** One worker per Bash call is one model turn per worker. `&` plus
|
||||
`wait`, as above, makes N workers cost about what one costs.
|
||||
- **Reconnaissance turns before the spawn.** A standalone preamble check, an
|
||||
`ls ~/codeman-cases`, a `list_sessions` "to see what is there": each is a whole
|
||||
model turn spent learning something this block already handles (line one performs
|
||||
the preamble check, invented names need no listing, and `spawn_worker` refuses
|
||||
collisions). A live two-worker run spent ~12 s of its 28 s total on exactly two
|
||||
such turns; the API work in between was under 10 s.
|
||||
- **Re-deriving the happy path** from §5.1 + §5.2 + §5.3 + §5.10. That is what the
|
||||
preamble functions exist to end. Compose them; do not rebuild them. The tells that
|
||||
you are rebuilding anyway: a `for` loop around `quick-start`, a poll on `.data.pid`,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+68
-1
@@ -2004,6 +2004,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) {
|
||||
@@ -2074,10 +2085,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'
|
||||
@@ -3992,7 +4059,7 @@ class CodemanApp {
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
|
||||
@@ -37,13 +37,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
async seedApprovals() {
|
||||
if (!this.approvals) this.approvals = new Map();
|
||||
this.approvals.clear();
|
||||
if (this.approvalsInboxEnabled()) {
|
||||
const data = await this._apiJson('/api/approvals');
|
||||
for (const item of (data && data.approvals) || []) {
|
||||
this.approvals.set(item.id, item);
|
||||
// Re-arm the tab alert state machine (idempotent set-add).
|
||||
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
|
||||
}
|
||||
// ⚠ Fetch and re-arm the tab-alert state machine REGARDLESS of the inbox
|
||||
// setting. The server-side approval store runs unconditionally (only the
|
||||
// inbox SURFACES are opt-in), and the red/yellow tab alert predates the
|
||||
// inbox: gating the seed on the setting meant that with the inbox off, a
|
||||
// reload landed with every alert store empty while a permission dialog sat
|
||||
// blocking a session (owner report 2026-08-15: rail said NEEDS YOU from
|
||||
// the live SSE event, the reloaded-elsewhere tab showed a plain green
|
||||
// dot). Only populating `this.approvals` (bell/drawer/answer strips) stays
|
||||
// behind the setting.
|
||||
const data = await this._apiJson('/api/approvals');
|
||||
const inboxOn = this.approvalsInboxEnabled();
|
||||
for (const item of (data && data.approvals) || []) {
|
||||
if (inboxOn) this.approvals.set(item.id, item);
|
||||
// Re-arm the tab alert state machine (idempotent set-add).
|
||||
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
|
||||
}
|
||||
this.renderApprovals();
|
||||
},
|
||||
@@ -68,14 +76,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onApprovalResolved(info) {
|
||||
if (!info || !info.id || !this.approvals) return;
|
||||
if (this.approvals.delete(info.id)) {
|
||||
// Clear the matching tab alert: the inbox resolves on more signals than
|
||||
// the hook handlers do (superseded, expired, answered from another
|
||||
// device), and clearPendingHooks is a no-op when nothing is set.
|
||||
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
||||
this.renderApprovals();
|
||||
}
|
||||
if (!info || !info.id) return;
|
||||
// Clear the matching tab alert UNCONDITIONALLY: the inbox resolves on more
|
||||
// signals than the hook handlers do (superseded, expired, answered from
|
||||
// another device), clearPendingHooks is a no-op when nothing is set, and
|
||||
// with the inbox setting OFF the item was never stored in `this.approvals`
|
||||
// even though seedApprovals armed the alert — gating the clear on a map hit
|
||||
// would strand that alert forever.
|
||||
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
||||
if (this.approvals?.delete(info.id)) this.renderApprovals();
|
||||
},
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────
|
||||
|
||||
+73
-20
@@ -222,23 +222,35 @@ function computeTabScrollLeft(input) {
|
||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and the first shipped numbers were tuned
|
||||
// against two tabs sitting side by side. A worker the agent skill starts is appended
|
||||
// to the END of the strip, so the real span between a lead and its worker is 800-1500px,
|
||||
// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as
|
||||
// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now
|
||||
// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket
|
||||
// survives the distance the feature is actually used at. The ceiling is what keeps a
|
||||
// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the
|
||||
// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||
// drawn through them (the retune's own first draft had exactly that regression).
|
||||
const LINEAGE_DIP_BASE_PX = 14;
|
||||
const LINEAGE_DIP_PER_PX = 0.085;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
const LINEAGE_DIP_MIN_PX = 22;
|
||||
const LINEAGE_DIP_MAX_PX = 104;
|
||||
const LINEAGE_DIP_MAX_PX = 64;
|
||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
// Lineage palette, assigned per CHILD in first-seen order and cycled (session-lineage.js).
|
||||
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||||
// which every skin block tunes for its own background, so a lone arc keeps the
|
||||
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
|
||||
// they ride the same double glow as the blue, which is what keeps them legible over
|
||||
// terminal text on every skin.
|
||||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||
|
||||
function computeLineagePath(input) {
|
||||
const parent = input?.parent;
|
||||
@@ -269,18 +281,19 @@ function computeLineagePath(input) {
|
||||
const cBottom = cTop + ch;
|
||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||
|
||||
// Both ends anchor on the tab BOTTOM, and the control points hang below whichever
|
||||
// row is lower, so one formula covers a flat strip and a wrapped one.
|
||||
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
|
||||
// sitting above further rows (see the corridor note above the constants).
|
||||
const span = Math.abs(cx - px);
|
||||
const rowDrop = Math.abs(cBottom - pBottom);
|
||||
// ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would
|
||||
// only reach the row gap again. Adding the row offset also keeps the curve clear of
|
||||
// the row it crosses instead of grazing its bottom edge.
|
||||
const stripBottom =
|
||||
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||
? Number(strip.top) + Number(strip.height)
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||
const dip =
|
||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||
depth * LINEAGE_SIBLING_STEP_PX +
|
||||
rowDrop;
|
||||
const yc = Math.max(pBottom, cBottom) + dip;
|
||||
depth * LINEAGE_SIBLING_STEP_PX;
|
||||
const yc = baseline + dip;
|
||||
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||
return { d, endX: cx, endY: cBottom, sameRow };
|
||||
}
|
||||
@@ -441,6 +454,7 @@ if (typeof window !== 'undefined') {
|
||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||
COLORS: LINEAGE_COLORS,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
compute: computeConnectionLossUi,
|
||||
@@ -879,6 +893,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 };
|
||||
}
|
||||
|
||||
@@ -1211,6 +1211,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window this session">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out button on this tab</span>
|
||||
<span class="set-row-desc">Show the open-in-a-window button on this tab even while the general App Settings toggle is off.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="sessionOptShowTabDetach" onchange="app.onSessionTabDetachToggle(this.checked)"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+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>`;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||
* @dependency constants.js (window.CodemanLineage.computePath)
|
||||
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||
*/
|
||||
@@ -90,6 +90,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
return edges;
|
||||
},
|
||||
|
||||
/**
|
||||
* Colour for one child's arc, from CodemanLineage.COLORS, assigned in FIRST-SEEN
|
||||
* order and remembered per child id. First-seen rather than draw-index keeps a
|
||||
* line's colour stable across re-renders, tab reorders and sibling closes (the
|
||||
* SVG is wiped and rebuilt constantly, so an index-based colour would flicker).
|
||||
* An empty string means "no override": the CSS falls back to --session-blue.
|
||||
*/
|
||||
_lineageColorFor(childId) {
|
||||
const palette = (window.CodemanLineage && window.CodemanLineage.COLORS) || [];
|
||||
if (palette.length === 0) return '';
|
||||
if (!this._lineageColorByChild) {
|
||||
this._lineageColorByChild = new Map();
|
||||
this._lineageColorNext = 0;
|
||||
}
|
||||
let idx = this._lineageColorByChild.get(childId);
|
||||
if (idx === undefined) {
|
||||
idx = this._lineageColorNext++ % palette.length;
|
||||
this._lineageColorByChild.set(childId, idx);
|
||||
// Bounded: entries for long-gone sessions are pruned once the map is clearly
|
||||
// stale, so a day-long dashboard cannot grow it without limit.
|
||||
if (this._lineageColorByChild.size > 200 && this.sessions) {
|
||||
for (const key of this._lineageColorByChild.keys()) {
|
||||
if (!this.sessions.has(key)) this._lineageColorByChild.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return palette[idx] || '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Append the lineage layer to the shared SVG pass.
|
||||
*
|
||||
@@ -137,6 +166,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||
// Per-child colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
const color = this._lineageColorFor(edge.childId);
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||
line.setAttribute('data-parent-tab', edge.parentId);
|
||||
@@ -153,6 +186,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1283,12 +1283,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Session Options Modal
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Per-TAB pop-out button override (Session Options → Session → Identity). The
|
||||
* general `showTabDetachButton` App Setting stays the per-device default for ALL
|
||||
* tabs; this map whitelists single sessions on top of it, so one tab can carry
|
||||
* the ⧉ button while the general toggle stays off. Per-device on purpose, like
|
||||
* the general setting: it is a display choice, so it lives in localStorage and
|
||||
* never touches the server schema. Rendered as the `tab-show-detach` class on
|
||||
* the tab (see _fullRenderSessionTabs), which styles.css exempts from the
|
||||
* global `display: none` gate; the active-tab reveal rules stay shared, so an
|
||||
* overridden tab behaves exactly like a tab under the general toggle.
|
||||
*/
|
||||
_tabDetachOverrides() {
|
||||
if (this._tabDetachOverrideMap === undefined) {
|
||||
try {
|
||||
this._tabDetachOverrideMap = JSON.parse(localStorage.getItem('codeman:tab-detach-overrides') || '{}') || {};
|
||||
} catch (_e) {
|
||||
this._tabDetachOverrideMap = {};
|
||||
}
|
||||
}
|
||||
return this._tabDetachOverrideMap;
|
||||
},
|
||||
|
||||
hasTabDetachOverride(sessionId) {
|
||||
return !!this._tabDetachOverrides()[sessionId];
|
||||
},
|
||||
|
||||
onSessionTabDetachToggle(on) {
|
||||
const id = this.editingSessionId;
|
||||
if (!id) return;
|
||||
const map = this._tabDetachOverrides();
|
||||
if (on) map[id] = 1;
|
||||
else delete map[id];
|
||||
// Prune ids whose sessions are gone, so closed sessions cannot grow the map.
|
||||
for (const key of Object.keys(map)) {
|
||||
if (key !== id && this.sessions && !this.sessions.has(key)) delete map[key];
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('codeman:tab-detach-overrides', JSON.stringify(map));
|
||||
} catch (_e) {
|
||||
/* storage full/blocked: the in-memory map still applies this page load */
|
||||
}
|
||||
// Apply to the LIVE tab directly: the debounced render may take the
|
||||
// incremental path (same session set), which patches rather than rebuilds,
|
||||
// so the template's class would only land on the next full render. Future
|
||||
// full renders re-emit it from _fullRenderSessionTabs.
|
||||
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) tab.classList.toggle('tab-show-detach', !!on);
|
||||
},
|
||||
|
||||
openSessionOptions(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.editingSessionId = sessionId;
|
||||
|
||||
// Per-tab pop-out override state (see _tabDetachOverrides above).
|
||||
const detachToggle = document.getElementById('sessionOptShowTabDetach');
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
+101
-20
@@ -1459,23 +1459,80 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Tab alert animations */
|
||||
.session-tab.tab-alert-action {
|
||||
/* Tab alerts: a STEADY red/yellow base with a pulse breathing on top.
|
||||
⚠ The original animation swung background AND border to transparent at its
|
||||
0%/100% keyframes, so for roughly half of every cycle an alerted tab was
|
||||
indistinguishable from a normal one: a glance (or a screenshot, owner report
|
||||
2026-08-15) read "no alert" while the home rail showed a steady NEEDS YOU.
|
||||
A pending permission is BLOCKING the agent, so the tab must look blocked at
|
||||
every instant; only the intensity is allowed to move. The status dot joins
|
||||
in (red/yellow, (0,4,0) so it outranks the skin block's (0,3,1) dot rules),
|
||||
mirroring the phone overview's red-row language. */
|
||||
/* The alert paints on ::before, NEVER on the tab element: .session-tab.active
|
||||
forces background/border/box-shadow with !important, and !important beats
|
||||
even a running animation, so an element-level alert vanished the moment the
|
||||
tab was selected. The permission is still blocking while you look at it, so
|
||||
the red ring must survive selection and clear only on resolution (owner call
|
||||
2026-08-15). Same convention as the entrance styles (see the tab-enter block).
|
||||
(0,3,x) via the strip parent on purpose: the non-OG skin block quiets
|
||||
decorative glows (`.tab-glow { box-shadow: none }` lands at (0,2,1)), and an
|
||||
alert halo is signal, not decor, so it must outrank that on every skin.
|
||||
The overlay paints above the tab's inline content (positioned vs flow), which
|
||||
is fine at these alphas and is exactly what keeps it visible over the active
|
||||
tab's opaque-ish background. */
|
||||
.session-tabs .session-tab.tab-alert-action::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
/* Explicit: .tab-enter::before (entrance animations) parks ::before at
|
||||
opacity 0 with fill-mode both, and an alerted tab that is also entering
|
||||
would otherwise inherit that and render an invisible alert. Our animation
|
||||
shorthand already displaces theirs at this specificity; the opacity must
|
||||
be pinned the same way. */
|
||||
opacity: 1;
|
||||
border: 2px solid var(--red);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||
animation: tab-blink-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.session-tab.tab-alert-idle {
|
||||
.session-tab.tab-alert-action .tab-status.idle,
|
||||
.session-tab.tab-alert-action .tab-status.busy,
|
||||
.session-tab.tab-alert-action .tab-status {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.session-tabs .session-tab.tab-alert-idle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 1; /* see the action variant above */
|
||||
border: 2px solid var(--yellow);
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
box-shadow: 0 0 8px rgba(234, 179, 8, 0.35);
|
||||
animation: tab-blink-yellow 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.session-tab.tab-alert-idle .tab-status.idle,
|
||||
.session-tab.tab-alert-idle .tab-status.busy,
|
||||
.session-tab.tab-alert-idle .tab-status {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 6px rgba(234, 179, 8, 0.6);
|
||||
}
|
||||
|
||||
@keyframes tab-blink-red {
|
||||
0%, 100% { background: transparent; border-color: transparent; }
|
||||
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
|
||||
0%, 100% { background: rgba(239, 68, 68, 0.12); box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); }
|
||||
50% { background: rgba(239, 68, 68, 0.3); box-shadow: 0 0 16px rgba(239, 68, 68, 0.75); }
|
||||
}
|
||||
|
||||
@keyframes tab-blink-yellow {
|
||||
0%, 100% { background: transparent; border-color: transparent; }
|
||||
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
|
||||
0%, 100% { background: rgba(234, 179, 8, 0.1); box-shadow: 0 0 8px rgba(234, 179, 8, 0.35); }
|
||||
50% { background: rgba(234, 179, 8, 0.24); box-shadow: 0 0 14px rgba(234, 179, 8, 0.65); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -2081,8 +2138,12 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
||||
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
||||
A tab that is ALREADY detached keeps its icon regardless: it is the
|
||||
re-focus affordance for the popped-out window. */
|
||||
html:not(.tabs-show-detach) .session-tab:not(.detached) .tab-detach {
|
||||
re-focus affordance for the popped-out window. A SINGLE tab can also opt in
|
||||
via Session Options → Session (`tab-show-detach` on the tab, per-device map
|
||||
in session-ui.js) while the general toggle stays off; the active-tab reveal
|
||||
rules above are shared, so the overridden tab behaves identically. Phones are
|
||||
unaffected either way: mobile.css hides .tab-detach with !important. */
|
||||
html:not(.tabs-show-detach) .session-tab:not(.detached):not(.tab-show-detach) .tab-detach {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -9329,11 +9390,15 @@ kbd {
|
||||
Deliberately quieter and thinner than the subagent lines above so the two
|
||||
layers read as different things in the same SVG.
|
||||
|
||||
Colour comes from --session-blue, which EVERY skin block already defines and
|
||||
already tunes for its own background, so one rule covers all seven (the four
|
||||
light skins included). Do not add a per-skin `.lineage-line` override inside the
|
||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1)
|
||||
and would outrank this one from a surprising place.
|
||||
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||
skin block already defines and tunes for its own background; a lone arc therefore
|
||||
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
||||
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
||||
class rule in there resolves to (0,2,1) and would outrank this one from a
|
||||
surprising place.
|
||||
|
||||
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
||||
lines in blue that they are better visible"). Violet sits close to the terminal's
|
||||
@@ -9352,13 +9417,13 @@ kbd {
|
||||
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||
exactly two dash cycles, so it has to move with them. */
|
||||
.connection-line.lineage-line {
|
||||
stroke: var(--session-blue, #2b8fd9);
|
||||
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-blue, #2b8fd9))
|
||||
drop-shadow(0 0 11px var(--session-blue, #2b8fd9));
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
@@ -9374,9 +9439,10 @@ kbd {
|
||||
}
|
||||
|
||||
.lineage-line-dot {
|
||||
fill: var(--session-blue, #2b8fd9);
|
||||
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 4px var(--session-blue, #2b8fd9)) drop-shadow(0 0 9px var(--session-blue, #2b8fd9));
|
||||
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
@@ -9789,13 +9855,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;
|
||||
@@ -12347,6 +12418,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;
|
||||
|
||||
@@ -1423,19 +1423,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;
|
||||
@@ -1454,9 +1454,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) {
|
||||
|
||||
@@ -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$/,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ function loadLineageHelper() {
|
||||
DIP_MIN_PX: number;
|
||||
DIP_MAX_PX: number;
|
||||
SIBLING_STEP_PX: number;
|
||||
COLORS: string[];
|
||||
};
|
||||
}
|
||||
).CodemanLineage;
|
||||
@@ -61,8 +62,9 @@ describe('lineage line geometry', () => {
|
||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
|
||||
|
||||
const nearDip = controlYs(near.d)[0] - 34;
|
||||
const farDip = controlYs(far.d)[0] - 34;
|
||||
// The dip hangs from the STRIP's bottom edge (40), not the tab bottoms.
|
||||
const nearDip = controlYs(near.d)[0] - 40;
|
||||
const farDip = controlYs(far.d)[0] - 40;
|
||||
expect(farDip).toBeGreaterThan(nearDip);
|
||||
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
|
||||
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
|
||||
@@ -80,15 +82,48 @@ describe('lineage line geometry', () => {
|
||||
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
||||
const helper = loadLineageHelper();
|
||||
// A worker the agent skill starts is appended to the END of the strip, so this
|
||||
// is the span the feature is actually used at. The first shipped clamp (44px)
|
||||
// turned it into a flat thread across the terminal.
|
||||
// is the span the feature is actually used at. The corridor has failed in BOTH
|
||||
// directions: the first 44px clamp read as a flat thread here (#285), and the
|
||||
// 104px clamp that replaced it bowed deep into the terminal (2026-08-15), so this
|
||||
// pins the cap exactly rather than just a floor.
|
||||
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||
|
||||
const wideDip = controlYs(wide.d)[0] - 34;
|
||||
const nearDip = controlYs(near.d)[0] - 34;
|
||||
const wideDip = controlYs(wide.d)[0] - 40; // from the strip's bottom edge
|
||||
const nearDip = controlYs(near.d)[0] - 40;
|
||||
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
||||
expect(wideDip).toBeGreaterThanOrEqual(80);
|
||||
expect(wideDip).toBe(helper.DIP_MAX_PX);
|
||||
expect(helper.DIP_MAX_PX).toBe(64);
|
||||
});
|
||||
|
||||
it('hangs the dip from the STRIP bottom, so no per-row offset ever stacks on it', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const twoRowStrip = { left: 0, top: 0, width: 1200, height: 84 }; // rows at y 4-34 and 48-78
|
||||
// A wrapped pair (row 1 → row 2) and a same-row pair on ROW 1 of the same strip.
|
||||
const wrapped = helper.computePath({ parent: tab(0), child: tab(400, 48), strip: twoRowStrip })!;
|
||||
const row1Pair = helper.computePath({ parent: tab(0), child: tab(400), strip: twoRowStrip })!;
|
||||
|
||||
// Both brackets clear the ENTIRE strip: the wrapped one does not add the row
|
||||
// offset on top (the 2026-08-15 over-bow), and the row-1 pair does not draw
|
||||
// through row 2's tab labels (the retune's own first-draft regression).
|
||||
for (const geom of [wrapped, row1Pair]) {
|
||||
for (const y of controlYs(geom.d)) {
|
||||
expect(y).toBeGreaterThanOrEqual(84 + helper.DIP_MIN_PX);
|
||||
expect(y).toBeLessThanOrEqual(84 + helper.DIP_MAX_PX + helper.SIBLING_STEP_PX);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes a colour palette whose first entry defers to the skin blue', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const colors = helper.COLORS;
|
||||
expect(Array.isArray(colors)).toBe(true);
|
||||
// '' = no override: session-lineage.js sets no inline --lineage-color and the
|
||||
// CSS falls back to the skin-tuned --session-blue, so a lone arc stays blue.
|
||||
expect(colors[0]).toBe('');
|
||||
expect(colors.length).toBeGreaterThanOrEqual(6);
|
||||
expect(new Set(colors).size).toBe(colors.length);
|
||||
for (const c of colors.slice(1)) expect(c).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
|
||||
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
||||
|
||||
Reference in New Issue
Block a user