Compare commits

...
Author SHA1 Message Date
Codeman maintainer a0ac10a07c feat(tunnel,ui): purple tunnel button + opt-in unauthenticated tunnel with warning (v1.1.9)
- Daylight Blue: Cloudflare Tunnel welcome button is now purple (was orange),
  keeping Claude blue / Tunnel purple / OpenCode green distinct.
- Allow enabling the Cloudflare tunnel with no CODEMAN_PASSWORD via the UI: the
  toggle now pops a security confirm dialog and, on confirm, sends an explicit
  per-request acknowledgeUnauthTunnel:true (new action field, never persisted).
  Server logs a loud warning whenever a passwordless public tunnel starts.
  curl/API/CLI stay refused unless password/env/flag — no accidental exposure.

Tests: extend test/routes/system-routes-tunnel-guard.test.ts (ack allows + not
persisted; ack:false still refuses). Verified e2e on an isolated instance
(purple button, confirm dialog, retry carries the flag, no real tunnel opened).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:44:03 +02:00
Codeman maintainer f7814ad364 feat(ui): distinct colors for welcome action buttons on Daylight Blue (v1.1.8)
On the default daylight-blue skin the three welcome buttons all read blue.
Give each its own identity: Run Claude Code keeps the blue accent, Cloudflare
Tunnel takes Cloudflare brand orange, Run OpenCode takes emerald green (with
matching hover/active states + dark ink for contrast). Scoped to daylight-blue
only; daylight-green and OG unchanged. Verified in-browser (blue/orange/green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:22:33 +02:00
Codeman maintainer 3172befd5d fix(terminal): keep Claude scrollback reachable — strip alt-screen/3J/mouse for claude mode (v1.1.7)
Terminal scroll-up intermittently broke for Claude sessions (most visible on
iPhone). Claude Code periodically emits alt-screen switches (?1049h/?47h/?1047h),
scrollback-erase (3J), and mouse-tracking enables for full-screen UIs, which move
xterm.js to the scrollback-less alt buffer / wipe saved lines / hijack the wheel.
Codeman stripped these but only for codex mode.

Share the strip via isAltScreenStripMode(mode) = codex || claude, applied at both
sites that were codex-only: the live PTY stream (Session._handleTerminalOutput,
incl. the chunk-boundary carry) and the /terminal buffer replay. shell stays
excluded (vim/less/htop need the alt screen); opencode unchanged.

Tests: test/claude-scrollback-strip.test.ts (8 new); codex strip tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:08:45 +02:00
Codeman maintainer 29ffc62536 fix(ultracode): pop floating windows on fresh devices loading mid-run (v1.1.6)
Re-run syncAllUltracodeFloatingWindows() after server settings load so a
first-time device whose getLightState run snapshot arrives before the async
settings fetch resolves still pops an already-active run's window immediately,
instead of waiting for the next ~10s watcher tick. Also fixes a stale
@fileoverview comment that named the wrong gating setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:27:10 +02:00
Codeman maintainer 4cb3a4aac8 fix(ultracode): (x) Close fully hides the Ultracode Agents panel
closeUltracodeAgentsPanel() only removed `open`, leaving the drawer in its
collapsed peek state (header strip still visible) — so (x) looked like a no-op.
Now also adds `hidden` (display:none), mirroring closeSubagentsPanel; does NOT
flip showUltracodeAgents (that gates the watcher + floating windows). Verified in
a real browser (post-close computed display:none). Bumps 1.1.4 -> 1.1.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:46:53 +02:00
Codeman maintainer b6531cbf79 fix(ultracode): floating windows pop for LIVE runs (watch transcript tree)
The Workflow runtime writes workflows/wf_<id>.json only at completion (always
terminal), so workflow-run-watcher never saw a run until it was already done and
the ACTIVE-gated floating window never popped. The watcher now also scans
subagents/workflows/wf_<id>/ and synthesizes a minimal running record (agentId
slots preserved for the transcript-click join, lastActivityAt from mtimes,
done/running from the journal), superseded by the real wf_<id>.json at
completion. Standalone (no subagent-watcher import). Verified e2e on a real
in-flight run; +6 unit tests. Bumps 1.1.3 -> 1.1.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:20:52 +02:00
Codeman maintainer d16bf34e34 feat(ultracode): floating run windows with tab connector lines + dedicated toggle
Auto-popping draggable window per active ultracode/Workflow run, connected by a
glowing line to its originating session tab (resolved via claudeSessionId ===
sessionUuid). Mirrors the live agent grid; auto-closes after a run finishes;
dismissals are remembered. Additional to the existing docked panel.

New "Ultracode Floating Windows" setting (default OFF), independent of the
"Ultracode Agents" panel toggle; either toggle starts the workflow-run watcher.

Also bumps version to 1.1.3 and brings CLAUDE.md up to date for the ultracode
subsystem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:33:54 +02:00
Codeman maintainer e6989bdb40 chore: version packages 2026-06-15 11:04:46 +02:00
Codeman maintainer 6ab6bbbbd4 feat(ultracode): Phase 4 — click an agent card to open its live transcript
Each workflow agent card with an agentId is now clickable and opens that agent's
live transcript in a popup, reusing the existing GET /api/subagents/:agentId/
transcript route. The workflow agent's agentId is byte-identical to the
agent-<id>.jsonl stem that subagent-watcher already tracks (via w16's
watchWorkflowDirs), so this is a pure client-side join — ZERO subagent-watcher
edits.

Graceful degradation: 'start' (queued) agents have no agentId yet and stay
non-clickable; an aged-out/untracked agent (subagent-watcher's 4h startup window,
or tracking disabled) returns an empty transcript and shows a friendly note
instead of an empty popup.

Verified on a live isolated server: the subagent transcript route serves a
workflow agent's transcript (150 entries) and the runId's agents[] carries the
matching agentId; Playwright confirmed clicking a card opens the transcript popup
with no console errors. frontend-syntax / public-assets / CSS-parse clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:49:22 +02:00
Codeman maintainer c15c19fab7 feat(ultracode): master-detail tab for Workflow/ultracode run visualization
Opt-in (showUltracodeAgents, default OFF) panel that visualizes ultracode /
Workflow-tool runs like Claude Code's "working agents" TUI: LEFT = runs + phases
(selectable tasks), RIGHT = each run's agents with model, live state, tokens
burned, and tool calls.

Standalone — ZERO edits to subagent-watcher.ts. A new workflow-run-watcher.ts
singleton globs the run-state tree (~/.claude/projects/*/*/workflows/wf_*.json,
disjoint from the transcript tree), strips the heavy script/scriptPath/result/logs
fields (174KB -> ~25KB/run), and emits workflow:run_* SSE events. The LEFT list
ships lightweight summaries (getLightState replay + SSE); the RIGHT pane fetches
the full run (with agents[]) via GET /api/workflows/:runId on selection.

Backend: workflow-run-watcher.ts, types/workflow-run.ts, config/workflow-config.ts,
3 SSE events, getLightState workflowRuns replay, GET /api/workflows[/:runId],
showUltracodeAgents schema key + boot-gate (default OFF) + live toggleService.
Frontend: ultracode-panel.js (debounced master-detail render, run/phase select),
header launcher (btn-ultracode-agents--hidden marker -> mobile-guard-exempt),
App Settings toggle (SYNCED, deliberately not in displayKeys).

Agent states on disk are start|progress|done (start=queued; done has
durationMs/resultPreview). Tests: workflow-run-watcher (9), workflow-routes (3).
Verified: tsc/lint/prettier/frontend-syntax/public-assets/mobile-header-guard
clean; full test:ci green (2986 passed); live server + Playwright e2e against 25
real runs (28-agent grid, phase filter, OFF hides launcher).

Design: docs/ultracode-agent-viz-plan.md (rev. 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:40:05 +02:00
Codeman maintainer f6a30d7335 fix(subagent-watcher): discover workflow-nested agents + harden meta→transcript upgrade
Two follow-ups to db93491 (the 2026-06 CC meta.json format change), after
reverse-engineering the new on-disk layout with a live current-CC subagent +
1Hz fs poller:

(1) Workflow recursion — the Workflow tool nests its agents at
    subagents/workflows/{wf}/agent-{id}.jsonl, one level below the flat
    subagents/ scan, so they were never tracked. Add watchWorkflowDirs()
    (driven from scanForSubagents) to descend and watch each workflow dir
    (idempotent; fs.watch recursive is unsupported on Linux, so the ~5s
    periodic scan re-drives it — same latency as new-session discovery).
    Require the `agent-` prefix in the flat readdir + watch callback so a
    workflow dir's sibling journal.jsonl can't register a bogus "journal" agent.
    E2E verified against real ~/.claude/projects: 32 workflow-nested agents
    discovered (wf_fa35c1d8-4a9), 0 bogus journal agents.

(2) Transcript timing — empirically the per-agent .jsonl IS written at the
    standard subagents/ path and grows incrementally (tailable); the
    /tmp/.../tasks/<id>.output the prior probe found is just a symlink back to
    it. meta.json lands at spawn, the .jsonl a beat later. Add a meta→transcript
    upgrade in registerAgentFile: when an agent registered meta-only gets its
    sibling .jsonl, re-point filePath, drop the stale sidecar context, start
    tailing, and emit subagent:updated (not a duplicate discovered). Corrects the
    now-inaccurate "no transcript to tail" doc comment on registerAgentMeta.

Tests: 2 new cases (workflow-nested discovery; journal.jsonl not registered).
All 56 pass; tsc/lint/format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:43:15 +02:00
Codeman maintainer db93491dd1 fix(subagent-watcher): discover subagents via agent-*.meta.json (CC format change)
Claude Code changed its subagent on-disk format (~2026-06-14): TUI Task
subagents now write `agent-{id}.meta.json` ({agentType,description,toolUseId})
into the session's `subagents/` dir and no longer reliably write a per-agent
`agent-{id}.jsonl` transcript there. The watcher discovered agents ONLY by
`.jsonl`, so it tracked zero — subagent windows and the monitor's "N TRACKED"
showed nothing.

- Add `registerAgentMeta()`: discover from the meta sidecar (description from
  meta.description/agentType), prefer a sibling `.jsonl` transcript when present
  (richer), never tail a meta file.
- Initial scan + directory watcher now handle `.meta.json` alongside `.jsonl`.
- Tests: 2 new cases (meta-only discovery; prefer-.jsonl-when-present).
  Verified e2e against a real ~/.claude/projects fixture.

Known follow-ups (not in scope): meta-only agents have no per-agent transcript
to tail (no live tool-call feed, status stays 'active'); workflow agents under
`subagents/workflows/{wf}/agent-*.jsonl` are still missed by the flat scan.

Also adds the README screenshot tooling used to surface this:
- capture-real-overview.mjs: DSF=2 + ?nowebgl crisp path (DOM renderer avoids
  the WebGL glyph-doubling at deviceScaleFactor>1).
- capture-readme-real.mjs: real-instance desktop-scene capture (dashboard/
  monitor/subagent) for an isolated beta seeded from prod settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:24:30 +02:00
Codeman maintainer b7ff54b2ec fix: file viewer opens audio/svg/binary like the attachments viewer
The File Browser preview and Attachments preview share openFilePreview(),
but the workspace branch (via /file-content) misclassified several types the
attachments viewer handled fine:

- SVG was reported as type:image, but file-raw serves SVG as octet-stream +
  attachment (XSS hardening), so the <img> broke. Now fetched and rendered via
  a same-origin image/svg+xml blob <img> (safe; <img> never runs SVG scripts).
  file-raw's SVG hardening is unchanged.
- Audio (mp3/wav/ogg/m4a/aac/flac/opus) was type:binary -> "Cannot preview".
  Now classified as audio and rendered with <audio controls>; file-raw gained
  the matching audio/video MIME types so playback works.
- Binary formats not in the hardcoded list (xlsx/doc/zip/...) were decoded as
  UTF-8 and dumped as mojibake. Replaced the static list with a NUL-byte
  content sniff that flags arbitrary binaries; the binary fallback now offers a
  Download link instead of dead-ending.

Adds route tests for audio, known-binary (xlsx), and NUL-sniff classification.
Verified end-to-end on an isolated instance + headless browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:42:12 +02:00
Codeman maintainer dc63d1f1a6 tools: harden real-overview screenshot capture + document DSF/cache gotchas
scripts/capture-real-overview.mjs:
- Default deviceScaleFactor to 1 (DSF=2 makes xterm's headless WebGL renderer
  draw console glyphs at ~2x while reporting nominal cell dims — invisible to
  cols/cell measurement, only the pixels reveal it; HTML chrome is unaffected so
  only the terminal font looks oversized)
- Mint a unique timestamped filename per run so a viewer/HTTP cache can't shadow
  a fresh capture with a stale render of a fixed path
- Seed per-device localStorage (skin, codeman-font-size, codeman-app-settings)
  so the capture reflects a real device: plan-usage chip shown (per-device key,
  deleted from server payload), side panels closed for a full-width terminal
- Support prod's self-signed HTTPS (ignoreHTTPSErrors), env-configurable viewport

CLAUDE.md:
- Document the DSF=1 / unique-filename screenshot gotcha (incl. the real
  Codeman-side immutable-static-asset cache footgun)
- Add the sanitize-html.js infra module (DOMPurify mXSS allowlist, COD-56) to the
  frontend module list and load order (was missing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:59:40 +02:00
Codeman maintainer 7c5920d3b9 chore: version packages 2026-06-14 23:06:32 +02:00
Ark0N e1e670594b Merge PR #128: auto-wrap desktop session tabs on overflow + resize re-eval
Auto-wrap desktop session tabs to a second row on overflow
2026-06-14 22:43:43 +02:00
Ark0N 2e28e17834 Merge PR #123: hide CJK textarea on welcome screen + mobile test update
fix(cjk): hide CJK textarea on welcome screen and fix vertical centering
2026-06-14 22:43:17 +02:00
Ark0N 90f18438ff Merge PR #127: require hook-event secret unconditionally + stale-config self-heal
Require the hook-event secret unconditionally (drop managed-tunnel gating)
2026-06-14 22:43:13 +02:00
Ark0N 5b62f397ec Merge PR #129: macOS Option/physical-key session shortcuts + terminal-ui ESC-leak fix
Make Option/Alt session shortcuts work on macOS (physical key codes)
2026-06-14 22:43:08 +02:00
Ark0N 1e54ebcdf4 Merge PR #125: add codeman doctor dependency checker + accuracy review fixes
Add `codeman doctor` tool-dependency checker
2026-06-14 22:43:04 +02:00
Ark0N 0364bea166 Merge PR #126: harden markdown sanitizer with DOMPurify (mXSS) + allowlist/test review fixes
Harden markdown HTML sanitizer with vendored DOMPurify (mXSS)
2026-06-14 22:42:59 +02:00
Claude (Codeman maintainer) c7e8ff616f fix(tabs): re-evaluate auto-wrap on resize and on every full tab rebuild
Review polish on the desktop tab auto-wrap:

- Auto-wrap is purely width-driven, but updateTabOverflowMode() was only called at the
  tail of _renderSessionTabsImmediate (SSE content renders). Window resize — the primary
  trigger for tabs crossing the one-row overflow threshold — never re-evaluated it, so
  narrowing/widening the window left the wrap state stale until an unrelated status event
  fired a render. Call it from the debounced window-resize handler (no-op on
  mobile/tablet, where the method bails).

- Move the re-evaluation into _fullRenderSessionTabs() as well, so the incremental
  branch's two early `_fullRenderSessionTabs(); return;` paths (badge add/remove, which
  change tab width) and the manual two-rows toggle (applyTabWrapSettings → _fullRender…)
  re-evaluate too. The latter also fixes a transient where enabling manual two-rows while
  auto-wrap was on left both classes set (clipping folder tabs to 96px) until the next
  render.

- Add boundary cases to the policy test: exact fit and the +1 sub-pixel tolerance (no
  wrap), 2px over (wrap), and a single overflowing tab (no wrap).

Verified: tab-overflow test passes; tsc, check:frontend-syntax, check:public-assets,
prettier all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:38:19 +02:00
Claude (Codeman maintainer) 21fbff4d8a fix(hooks): self-heal stale pre-secret hook configs so COD-91 doesn't 401 them
Making the hook-event secret unconditionally required closes the own-loopback-proxy gap,
but it would also silently 401 the hook curls baked into cases created BEFORE the secret
header existed (COD-54, 2026-06-10): writeHooksConfig only runs at case CREATION, so an
existing/linked case on a password-protected install keeps secret-less curls that the new
gate rejects (degrading idle/stop/teammate/task signalling with no error surfaced).
No-password installs are unaffected — the gate isn't registered without CODEMAN_PASSWORD.

Add `refreshStaleHookSecret(casePath)` and call it on Claude-mode spawns in
POST /api/sessions and POST /api/quick-start (existing-case branch). It regenerates the
hooks block ONLY when settings.local.json already holds Codeman's own hook curls (they
target /api/hook-event) that lack the X-Codeman-Hook-Secret header — a no-op when the
hooks are absent, not ours, or already current, so it never clobbers user customizations
and is cheap on every spawn. Fresh cases are unaffected (writeHooksConfig already wrote
the secret). withSettingsLock serializes it with the model/statusLine writers.

Verified: new test/hook-secret-selfheal.test.ts 5/5 (heal + key-preservation + no-op on
current/foreign/absent/malformed); the PR's cod54 + auth-security suites still pass
(36); tsc, lint, format:check, and npm run build all clean (symbol present in dist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:35:29 +02:00
Claude (Codeman maintainer) 8ffb2b0644 test(cjk): update the mobile server-override test for the welcome-screen gate
The PR gates CJK textarea visibility on an active session
(`showCjk = cjkUserEnabled && !!activeSessionId`) so the fixed-position textarea no
longer floats over the welcome overlay. That intentionally changes the behavior the
existing `shows the CJK textarea on mobile only for server override` test asserted —
it set `_serverCjkOverride = true` on a fresh page (no active session) and expected the
textarea visible, which now (correctly) resolves to hidden. The test lives in
test/mobile/** (excluded from CI), so it wasn't caught by the PR's green CI.

Update the test to verify the new, intended behavior: with the server override on it
stays hidden on the welcome screen (no active session) and is revealed once a session
is active. This is a co-authored review fix; the original change is TeigenZhang's.

Verified: tsc, check:frontend-syntax, check:public-assets, prettier all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:30:09 +02:00
Claude (Codeman maintainer) 80ebf8b549 fix(shortcuts): stop Alt/Option nav keys leaking ESC sequences into the terminal
The PR migrated the app.js tab-nav handler to physical e.code but left xterm's
pass-through gate (terminal-ui.js) matching ev.key digits. Consequences:

- Alt+[ / Alt+] (the new bindings) were never in the gate, so xterm sent ESC[ / ESC]
  to the PTY on every platform AS WELL AS switching the session.
- Alt+digit on a remapped macOS Option layout (Option+1 -> "¡") didn't match the
  ev.key '0'-'9' gate either, so xterm injected ESC<char> — on exactly the layouts
  this PR exists to fix.

Update the xterm gate to mirror app.js exactly: suppress when
`ev.altKey && !ctrl && !shift && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code)`.
Returning false there tells xterm not to write to the PTY, so the shortcut switches
the tab with no stray escape sequence.

Also: relabel the docs Alt/Option (the mechanism is layout/OS-independent, so the
shortcut works for Linux/Windows Alt users too — "Option" alone was Mac-only wording),
and add a keyboard-shortcuts test asserting terminal-ui.js gates on the same physical
codes so this desync can't regress (a grep the original test missed).

Verified: keyboard-shortcuts test 4/4, check:frontend-syntax, check:public-assets,
format:check all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:27:36 +02:00
Claude (Codeman maintainer) c101cc8716 fix(doctor): correct Node minimum, drop phantom gemini, add pdftoppm, validate --category
Review fixes on top of the `codeman doctor` checker:

- Node minVersion 18.0.0 -> 22.0.0. package.json engines is ">=22.0.0" and the docs/CI
  require Node 22+, so doctor was green-lighting Node 18-21 (a false pass).
- Remove the phantom `gemini` registry entry. Codeman has no Gemini backend
  (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'); the entry advertised a
  dependency that nothing uses.
- Add `pdftoppm` (poppler) to the office group. document-thumbnailer.ts calls pdftoppm
  with no fallback as the sole PDF/Office first-page thumbnail renderer, yet it was
  absent from the registry, so doctor never reported it missing.
- Fix the `--category` mismatch: the help advertised `documents|media` categories that
  the ToolCategory type/registry never defined, and an unknown category silently
  produced an empty "all healthy" table. Introduce TOOL_CATEGORIES as the single source
  of truth (type + help + validation); an invalid `--category` now errors with the
  valid list and exits 2.

Verified: tsc, lint, format:check all clean; both dependency tests pass (20);
`doctor` runs correctly (Node 22.22 ok, pdftoppm detected, no gemini), `--category media`
errors with exit 2, `--category office` lists libreoffice/pdftoppm/msoffice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:24:46 +02:00
Claude (Codeman maintainer) cceb24ed8f fix(sanitizer): enforce the curated allowlist + make the test run order-independently
Review fixes on top of the DOMPurify mXSS hardening:

- Remove `USE_PROFILES: { html: true }` from the sanitize-html.js config. DOMPurify
  treats USE_PROFILES and ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — with a
  profile set it resets the allow-lists to the full HTML profile and silently ignores
  the curated lists, so the tight markdown-only allowlist was dead config (still
  XSS-safe via FORBID + core, but far broader than intended: <button>/<input>/
  <details>/<audio>/<select>/<label> all survived). Dropping USE_PROFILES puts the
  curated ALLOWED_TAGS/ALLOWED_ATTR back in force; FORBID_TAGS/FORBID_ATTR stay as
  defense-in-depth and DOMPurify keeps its default safe-URI handling.

- Rewrite test/markdown-sanitizer.test.ts to run in the default node environment with
  an in-test jsdom window instead of a per-file jsdom environment. That environment
  externalizes node:fs/node:path under vite, so the suite failed to load in isolation
  ("No such built-in module: node:") and only survived the full CI run because an
  earlier node-env test happened to pre-cache node:fs — order-dependent and fragile.
  The rewrite is order-robust and adds an "allowlist is actually enforced" block
  (non-markdown tags must be dropped) that fails if USE_PROFILES is reintroduced.

Verified: 25/25 tests pass standalone under config/vitest.ci.config.ts; tsc, lint,
format:check, check:frontend-syntax, check:public-assets, and npm run build all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:21:13 +02:00
Aamer Akhter 60dab7ce3f Make Option/Alt session shortcuts work on macOS (physical key codes)
Tab-switch shortcuts matched e.key, so on macOS Option+1 emits a special
character ('¡', not '1') and the shortcut silently failed. Switch to physical
e.code (Digit1-9), which is layout-independent. Also adds Option+[ / Option+]
for previous / next session. Help modal + README updated.

Test: test/keyboard-shortcuts.test.ts.
2026-06-14 15:54:50 -04:00
Aamer Akhter a5263b3252 Auto-wrap desktop session tabs to a second row on overflow
When desktop session tabs overflow one row, wrap them to a second row instead
of horizontal scroll — unless the user has pinned the manual two-row layout
(tabTwoRows). Mobile/tablet keep horizontal scroll. The wrap policy
(shouldAutoWrapTabs) lives in constants.js as a pure, unit-testable function;
updateTabOverflowMode() measures overflow after each tab render and toggles
.tabs-auto-wrap.

Test: test/tab-overflow.test.ts (vm-loads constants.js, asserts the policy).
2026-06-14 15:49:12 -04:00
Claude (Codeman maintainer) 90cd481b9f chore: version packages
Release 1.1.0. Headline: opt-in Plan Usage Limits chip (per-device live
5h/weekly plan %), attachment history drawer + opt-in Attachments button,
Opus 4.6 model options, and mobile header regression guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:39:14 +02:00
Claude (Codeman maintainer) 787e5e2a03 feat(attachments): make the header attachments button opt-in (default OFF)
The COD-39 attachments button was hard-visible in the header — first on
mobile, then (after the mobile-only hide) still on desktop. Make it a
proper opt-in App Settings → Display toggle ("Attachments Button"),
default OFF everywhere, mirroring the Response Viewer button:

- index.html: button ships with the `btn-attachments-history--hidden`
  marker; new settings checkbox #appSettingsShowAttachmentsButton.
- styles.css: base `display:inline-flex !important` + a more-specific
  `--hidden` rule (same pattern as the response viewer).
- settings-ui.js: load/save/getDefaultSettings(false) + a live toggle in
  applyHeaderVisibilitySettings. Per-device and NON-leaking — added to
  displayKeys AND stripped from the server payload, so enabling it on
  desktop never makes it appear on mobile (or any other device). No
  server-side render step (purely client display, like the eye button).
- mobile.css: dropped the now-redundant phone-only hide — the opt-in
  marker hides it everywhere by default; the per-device toggle governs
  both desktop and phone.

Tests updated: the CI static guard drops btn-attachments-history from the
phone-hidden lock (it's opt-in now, excluded from the default-visible
enumeration — the guard still gates any NEW default-visible button); the
real-browser E2E now asserts default-hidden on a desktop-class viewport
and visible after enabling the setting.

Verified on a real desktop browser: hidden by default, the settings
toggle exists, enabling it shows the button. tsc + frontend-syntax +
prettier + public-asset checks + both test suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:21:06 +02:00
Claude (Codeman maintainer) 097433c86f docs: update CLAUDE.md for the attachments subsystem growth
Document the three PRs that grew attachments since the last update:
COD-37/#119 (registry + magic links) was already covered, but
COD-38/#120 (document previews/thumbnails) and COD-39/#121 (history
drawer) added four source files and several endpoints that weren't
documented. Split a dedicated Attachments row out of Infra, extend the
Attachments Key Pattern to cover the converter pipeline + concurrency
limiter + history drawer, and refresh the files-route handler count
(8 -> 14) and total (~140 -> ~146). Also carries the prior pending
app.js line-count (3.7K -> 3.9K) and config-file-count (10 -> 12) bumps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:13:36 +02:00
Claude (Codeman maintainer) e738c776c1 fix(settings): slim the Skin picker select to match its row
The skin picker inherited .form-select's 0.8rem font + 0.5rem vertical
padding, rendering bigger and taller than the settings row it sits in
(0.75rem / 0.45rem). The daylight skins' Manrope font exaggerated it,
so "Daylight Blue" looked oversized and the field too thick. Scope a
0.75rem font + 0.3rem vertical padding to .settings-item-skin .form-select
so the field text matches the row label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:08:18 +02:00
Claude (Codeman maintainer) e10f0dabdb fix(mobile): hide attachments-history button on phones + regression guards
The COD-39 attachment-history header button was visible on the cramped
phone header. Hide it on phones alongside the settings gear and lifecycle
log (the mobile header is intentionally minimal — those controls live in
the toolbar). One-line addition to the existing @media (max-width: 430px)
display:none block in mobile.css.

This is the second time a header control leaked onto mobile (the
plan-usage chip was the first), so add two regression guards:

- test/mobile-header-buttons-policy.test.ts — a pure static analysis of
  index.html + mobile.css (no browser), so it runs in the normal CI sweep
  (the test/mobile/** Playwright suite is EXCLUDED from CI and never gated
  this). It enumerates every default-visible header button and fails when
  one has no phone-visibility decision — either a mobile.css hide rule or
  an explicit MOBILE_VISIBLE_ALLOWLIST entry. A new header button now
  forces that decision. Verified it fails on the pre-fix state and passes
  after.
- test/mobile/header-buttons.test.ts — real-browser E2E in the mobile
  suite: asserts the attachments/settings/lifecycle buttons are hidden on
  an emulated iPhone 14 Pro and the attachments button is visible on a
  desktop-class tablet.

tsc + lint + prettier + both new tests green. Only CSS + tests changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:45:23 +02:00
Claude (Codeman maintainer) 661c89cefd fix(plan-usage): make the usage chip per-device, not synced
The plan-usage header chip (5h/7d %) was a SYNCED setting, so enabling
it on desktop turned it on for mobile too — even though the user never
enabled it there. Make the chip's DISPLAY purely per-device (default
OFF) like the response viewer / skin, while keeping telemetry COLLECTION
server-side.

Three leak sources fixed:
- server.ts renderIndexHtml force-revealed the chip from the synced
  value (pre-paint), pushing the desktop choice onto every device.
  Removed — the chip now ships hidden and the client reveals it
  per-device via applyHeaderVisibilitySettings.
- settings-ui.js load-merge let the server value win, writing desktop's
  `true` into the (separate) mobile settings blob. showPlanUsageLimits
  is now a displayKey AND is dropped from the server payload on load, so
  a stale server value is never seeded into a device that didn't enable
  it. It's also stripped from the save payload so a mobile "off" can't
  clobber the server.
- Collection was gated on the same synced flag. Decoupled via a new
  `statusLineTelemetry` ACTION field (schema + system-routes): sent on
  ENABLE only and never persisted, so the exporter is injected when a
  device turns the chip on but is never yanked when another device has
  it off (it's shared across sibling sessions). Session-create already
  reads the per-device blob, so that path was already correct.

One-time migration clears a stale synced `true` from the mobile blob so
existing mobile installs default to OFF without a manual toggle.

Verified end-to-end on an isolated server: with showPlanUsageLimits=true
persisted, the rendered HTML ships the chip hidden; a fresh browser
context (mobile case) keeps it hidden while a context that explicitly
enabled it shows it; the PUT accepts statusLineTelemetry and does not
persist it. tsc + frontend-syntax + system-routes/index tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:15:39 +02:00
Ark0N a122e867ef Merge PR #122: restore response-viewer eye button on mobile
Remove the dead mobile-collapsed header tray that hid the entire header-right cluster (incl. the opt-in response-viewer eye) on phones/tablets, and update the mobile test to assert inline reachability. Eye stays hidden by default (showResponseViewer).
2026-06-14 19:46:11 +02:00
Claude (Codeman maintainer) a68f23e647 test(mobile): assert header tray reachable inline, not collapsed (#122)
Removing the dead `mobile-collapsed` tray (this PR) means the test that
asserted the headerRight tray *stays collapsed* on mobile now contradicts
the code and would fail when run. Flip it: with the three-dot utility
toggle gone, the header-right utilities must flow inline and stay
reachable on small viewports. The response-viewer eye itself remains
hidden by default (showResponseViewer opt-in), so this only re-exposes
the already-default-visible utilities inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:51 +02:00
Aamer Akhter f0f43ddbad Require the hook-event secret unconditionally, not only under a managed tunnel
COD-54 gated the /api/hook-event + /api/status-telemetry localhost bypass
behind the shared X-Codeman-Hook-Secret only WHILE a managed tunnel was
running, keeping a plain localhost bypass otherwise. But Codeman can't detect
a user's OWN loopback reverse proxy (their own `cloudflared --url`,
`tailscale serve`, nginx -> 127.0.0.1), which proxies internet traffic into
the loopback origin with req.ip === 127.0.0.1 — so that setup kept the unsafe
plain bypass.

Require the secret on the loopback bypass unconditionally. Managed-session
hooks already always present it (X-Codeman-Hook-Secret from
$CODEMAN_HOOK_SECRET_FILE, generated for every instance), so the legitimate
hook channel is unaffected; only the previously-unguarded own-proxy path is
now rejected. Drops the now-unused getTunnelRunning param from
registerAuthMiddleware.

Tests: cod54-hook-event-auth (tunnel-down now also requires the secret, plus
a good-secret positive case); auth-security (hook tests present the secret to
reach schema validation).
2026-06-14 12:46:58 -04:00
Aamer Akhter ea53916adc Replace markdown denylist sanitizer with vendored DOMPurify (mXSS hardening)
The previous _sanitizeHtml was a denylist over agent/transcript markdown
rendered via innerHTML; it missed style attributes and the svg/math mXSS
namespaces — e.g. <svg><style><img src=x onerror=alert(1)></style></svg>
re-serialized into a live <img onerror>.

Vendor DOMPurify 3.4.8 (allowlist) following the existing marked.min.js
vendor pattern (same-origin, CSP script-src 'self'; not in package.json so
no lockfile drift). New sanitize-html.js wires a hardened allowlist config
(FORBID style/svg/math/script/iframe/object/embed/form; no data attrs);
app.js _sanitizeHtml delegates to it with a fail-closed escape-all fallback.
index.html loads dompurify -> sanitize-html -> app.js (defer); build.mjs
minifies + content-hashes sanitize-html.js.

Test: test/markdown-sanitizer.test.ts (jsdom, real shipping artifacts) —
mXSS payloads neutralized + legit markdown preserved.
2026-06-14 12:33:48 -04:00
Aamer Akhter 585127deb2 Add codeman doctor tool-dependency checker (COD-45)
Environment-aware dependency probe (linux|darwin|win32|wsl) with a static
registry, an injectable ProbeHost seam for testing, grouped table + `--json`
output, and a non-zero exit when a required dependency is missing/outdated.
Node and tmux are the only hard-required tools; the agent CLIs and document
converters (LibreOffice / MS Office via WSL interop) are optional. CI-safe
unit tests (no tmux, injected host).
2026-06-14 12:25:49 -04:00
Claude (Codeman maintainer) e742d00c98 Merge PR #121: attachment history drawer (COD-39)
Per-session attachment history with a slide-in drawer, unread badge, and
re-show. Rebased onto master (stacked on #120) + review hardening (malformed-
history recovery guard, resilient list route, badge positioning, debounce
cancel, stable re-show, Escape-to-close, CSS token fixes). See PR #121.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:34:41 +02:00
Claude (Codeman maintainer) 1a363a3e62 fix(attachments): address review findings on attachment history drawer (#121)
Follow-up fixes applied during review of PR #121 (all confirmed minor/nit;
no blockers). Security posture verified sound (externalPath never leaves
toState()/the list route; re-registration runs the guard).

- fix(recovery): restoreAttachmentHistory now skips malformed/legacy saved
  items (null, non-object, missing source/fileName) instead of throwing inside
  the Session constructor — a corrupt __attachmentHistory entry could otherwise
  abort the entire mux-recovery loop. (P1)
- fix(routes): the attachment-list route degrades a single failing entry to
  {missing:true} instead of failing the whole drawer. (INT-4)
- fix(ui): give the attachments header button a positioning context so the
  unread badge anchors to the icon, not the header bar. (F1/CSS-1)
- fix(ui): cancel the debounced history refresh on drawer close and guard it
  against a stale session/closed drawer. (F3)
- fix(ui): re-show ("Card") of a detected item now uses the item's own
  timestamp so the cardId is stable — focuses the existing card instead of
  stacking duplicates. (F4)
- fix(ui): Escape now closes the drawer, matching every other panel. (UX-1)
- fix(ui): badge shows "99+" past 99 (was an inconsistent 100/99 cap). (BADGE-1)
- style: drop the duplicate @keyframes notif-badge-pulse (dead CSS). (INT-1/CSS-3)
- style: empty-state used three undefined CSS custom properties
  (--text-primary/--border-color/--bg-tertiary) → use the defined
  --text/--border-light/--bg-input tokens. (CSS-2)
- test: add constructor restore round-trip + malformed-item resilience tests.

Deferred (noted for author): broadcasting the full 100-item history in every
session-state SSE event (payload bloat), "unread" badge semantics, making the
header button opt-in, and app.inject route tests for the two new endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:30:01 +02:00
Aamer Akhter 577b6d7384 COD-39 attachment history drawer
Stacks on COD-38: accumulates a per-session attachment history and exposes it
through a slide-in drawer with an unread badge, so attachments stay reachable
after their cards are dismissed.

Backend:
- session-attachment-history: history state — dedupe by source path / relative
  path, newest-first, 100-item cap, and externalPath sanitization (the absolute
  host path is server-private and never leaves toState()).
- session.ts: _attachmentHistory + getter (sanitized) / upsert / restore /
  getAttachmentHistoryForPersist; restored from saved state in the constructor.
- file-routes: GET /attachments (list — resolves each entry to live metadata +
  routes; external entries are re-registered) and GET /attachments/:id
  (metadata poll). The by-id route guards via the registry's TOCTOU-safe
  resolveServableAttachmentPath.
- server.ts: detected/registered attachments upsert into history and persist;
  the private (externalPath-bearing) history rides on disk under
  __attachmentHistory, separate from the sanitized public copy, and is restored
  on mux-session recovery.
- types/session.ts: SessionAttachmentHistoryItem + SessionState.attachmentHistory.

Frontend:
- panels-ui: the drawer (lazy-built), unread badge, list render with per-item
  preview/download/open/"Card" (reshow) actions, and live refresh of the open
  drawer on new detections.
- app.js: history state + per-session badge/cleanup wiring.
- index.html / styles.css / mobile.css: header button + badge and the drawer.

Verified: tsc / eslint / prettier / frontend-syntax / public-assets clean; new
history-module unit tests pass; full test:ci green (2866 passed); badge, drawer
open/render/reshow/close verified in-browser.
2026-06-14 09:05:17 +02:00
Claude (Codeman maintainer) 5eacb1cf03 Merge PR #120: document attachment previews + thumbnails (COD-38)
Adds attachment cards with first-page thumbnails and inline document
previews (PDF/Office via pdftoppm + LibreOffice), plus review hardening
(converter concurrency limiter, bounded preview cache, fixed detected-doc
preview routing). See PR #120.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/web/public/styles.css
2026-06-14 08:49:36 +02:00
Claude (Codeman maintainer) 5fbe451c26 fix(attachments): harden document preview/thumbnail path (review of #120)
Follow-up hardening applied during review of PR #120, addressing the
adversarial multi-agent findings:

- fix(preview): render auto-detected (workspace, unregistered) DOCX/PPTX via
  the file-preview route and PDFs via file-raw in openFilePreview. Previously
  the Preview button fell through to file-content, dumping the binary Office/PDF
  bytes as mojibake, and the new file-preview route was unreachable dead code.
  (MAJOR: file-preview-route-unreachable-detected-office)

- perf(convert): add a global converter-concurrency limiter
  (document-conversion-limiter.ts) wrapping every pdftoppm / soffice /
  powershell spawn, so N simultaneous preview/thumbnail requests can no longer
  fork unbounded converter processes. Default cap 3, CODEMAN_MAX_DOCUMENT_CONVERSIONS.
  (MAJOR: no-converter-concurrency-limit)

- fix(cache): bound the converted-PDF disk cache with LRU-by-mtime eviction
  (pruneDocumentPreviewCache, default 100 files, CODEMAN_MAX_PREVIEW_CACHE_FILES),
  run after each successful conversion. Was unbounded.
  (MAJOR/MINOR: preview-cache-unbounded-disk-growth)

Tests: document-conversion-limiter.test.ts, document-preview-cache-eviction.test.ts,
and route coverage for the four new endpoints in
routes/file-routes-preview-thumbnail.test.ts (closes the missing-route-test gap).
Verified end-to-end against real pdftoppm (thumbnail render + concurrency cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:43:57 +02:00
Tenggan ZhangandTeigen 99e537ef1a feat(settings): add Opus 4.6 model options to Claude Model picker (#124)
Add claude-opus-4-6[1m] (1M context) and claude-opus-4-6 to the model
selector dropdown.

Co-authored-by: Teigen <teigenzhang@gmail.com>
2026-06-14 08:13:13 +02:00
arkonandClaude Opus 4.8 67c7973aa5 docs: document plan-usage telemetry feature in CLAUDE.md
- New "Plan-usage chip" Key Pattern: statusLine telemetry (rate_limits) →
  injected statusLine exporter → POST /api/status-telemetry (auth-exempt) →
  usage-telemetry.ts parse → SSE session:statusTelemetry → opt-in header chip,
  with plan-usage-latest.ts replaying the last value in the SSE init snapshot.
- Architecture map: add src/usage-telemetry.ts + src/web/plan-usage-latest.ts;
  bump route modules 15→16 and handlers ~136→~140 (status-telemetry route,
  attachment file routes).
- Security: note /api/status-telemetry shares the hook auth-bypass path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:34:02 +02:00
arkonandClaude Opus 4.8 534712e50f fix(usage): address code-review findings in plan-usage telemetry
Review of the plan-usage chip feature (commits since 1.0.0) surfaced several
issues; this fixes all confirmed findings:

- HIGH: applyStatusLineConfig clobbered a user's hand-authored statusLine on
  the enable path (the isOurs guard only protected disable). Now bails out when
  an existing statusLine isn't ours, on both the enable and disable paths.
- MED: StatusTelemetrySchema used z.optional() (rejects null) on Claude's
  undocumented statusline fields — a single stray null 400'd the entire POST and
  silently killed the chip's data feed. Switched the modeled fields to .nullish().
- MED: dropping the Token Count / Show Cost header toggles left their features
  reading settings.showTokenCount/showCost, but saveAppSettings rebuilds settings
  fresh from the DOM, dropping those keys and resetting them to defaults on every
  save (re-enabling the token chip with no UI to turn it off). Preserve the prior
  stored preference.
- telemetrySignature keyed on contextUsedPercentage (never displayed) and the raw
  unrounded %, churning a redundant SSE broadcast + localStorage write + identical
  chip re-render on every assistant message. Now keys on the rounded displayed
  window values only.
- Plan-usage chip flashed hidden on load (no server-side reveal): renderIndexHtml
  now strips header-plan-usage--hidden when enabled, matching btn-multimonitor;
  fixes the FOUC and makes the "server renders initial state" comments accurate.
- Serialize all settings.local.json read-modify-write writers in hooks-config via
  a shared per-path mutex (previously lock-free; concurrent session-create +
  settings-toggle on the same repo could lose writes).
- Hardened the chip's innerHTML against any future string field; removed the dead
  _latestPlanUsage field; clamped ctx% in the footer formatter; corrected the
  session-create comment (the path is add-only by design — a per-repo settings
  file is shared by sibling sessions).
- Tests: new test/routes/status-telemetry-routes.test.ts (route behavior, dedup,
  null-tolerance) + NaN/Infinity/fractional and signature-churn unit tests; made
  server-index-title.test.ts deterministic against the ambient settings.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:33:52 +02:00
arkonandClaude Opus 4.8 f69cd4874c feat(settings): drop Token Count + Show Cost header toggles, move Plan Usage Limits to top
The header Token Count and Show Cost ($) display options are superseded by the
Plan Usage Limits chip, so remove both toggles from App Settings → Header
Displays along with their read (populate) and write (save payload) wiring in
settings-ui.js. Relocate the Plan Usage Limits toggle to the top of the section
for easier access.

Header token-chip render logic is left intact (toggles-only change): the chip
keeps its existing default behavior, it's just no longer user-toggleable.

Verified e2e against an isolated instance with Playwright: section now leads
with Plan Usage Limits; Token Count/Show Cost elements are gone; openAppSettings
(populate) and saveAppSettings (payload build) run with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:44:42 +02:00
arkonandClaude Opus 4.8 1ac3c09054 docs(usage): update plan-usage design doc to match what shipped
Rewrite to the as-built design: header chip (account limits, green/yellow/red)
+ session-status footer split; fixed /api/status-telemetry endpoint; curl -sk;
add-only create injection + settings-toggle reconcile; no CASES_DIR gate; chip
robustness (live SSE + init-snapshot replay + localStorage); and the E2E bugs
that earlier builds hid. Status: shipped/pushed, not released.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:28:11 +02:00
arkonandClaude Opus 4.8 95fb5fc226 feat(usage): replay last-known plan usage in the SSE init snapshot
The header chip previously only repopulated on reload from per-browser
localStorage, so a fresh browser (or cleared storage) stayed blank until a
session next rendered telemetry. Store the latest broadcast telemetry
process-wide (plan-usage-latest.ts) and include it as `planUsage` in
getLightState — the per-connection SSE init snapshot — so handleInit paints
the chip immediately on every fresh load / reconnect, authoritative over the
localStorage restore. Null until the first telemetry of the process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:15:34 +02:00
arkonandClaude Opus 4.8 eae225bf9a fix(usage): make plan-usage chip work for every user, not just on enable
Two changes so the feature works for any user the moment they enable it,
without manual steps or per-client state:

- Reconcile on settings change: PUT /api/settings now applies the statusLine
  exporter across all ACTIVE Claude sessions' working dirs when
  showPlanUsageLimits is toggled (inject on enable, remove on disable). This is
  server-side and authoritative, so existing sessions get the footer + feed the
  chip immediately — no need to create a new session, no dependency on a
  browser's synced localStorage.

- Create is now ADD-ONLY: never remove the statusLine on session create.
  Sessions in a repo share one settings.local.json, so a single create-with-false
  (e.g. a client whose synced setting hadn't loaded) was yanking the statusLine
  out from under all other live sessions in that repo, killing their footer and
  the chip's data feed. Removal now happens only via the explicit settings toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:00:30 +02:00
arkonandClaude Opus 4.8 4d9d93dfff fix(usage): make plan-usage chip work end-to-end + session-status footer
End-to-end testing on the real install surfaced several issues the unit
tests missed:

- Injection gate excluded real sessions: gated on workingDir under CASES_DIR,
  but sessions run in linked cases / real repos. Drop the gate (match
  updateCaseModel, which writes settings.local.json unconditionally).
- statusLine curl failed on HTTPS: prod is loopback HTTPS with a self-signed
  cert; `curl -s` returns 000. Use `curl -sk` (loopback only). applyStatusLineConfig
  now also updates an out-of-date ours-command so the fix propagates.
- Footer hijacked by limits: the in-terminal statusline now shows CURRENT
  SESSION status — `Opus 4.8 (1M context)  in:562,411 out:1,188  ctx:56%` —
  while the account-wide plan limits live only in the header chip.
- Chip blank after reload: persist last-known to localStorage and restore on
  load (account-global, slow-moving; 12h freshness guard).
- Readability + color: per-window green/yellow/red by usage (<60 / 60–84 / ≥85),
  bolder labels and values.
- Drop the renderIndexHtml strip (client-side reveal only, response-viewer
  pattern) — fixes server-index-title test fragility to local settings.

Footer fields flow through context_window.total_input_tokens/total_output_tokens
(schema + parser). Tests updated; verified live (footer, chip, colors, reload
persistence) on the real install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:44:54 +02:00
arkonandClaude Opus 4.8 c82f6c802e feat(usage): plan usage limits header chip via statusLine telemetry
Surface Claude subscription plan usage limits (5-hour rolling + 7-day
weekly: percent used + reset time) in the header, opt-in via App Settings
→ Display → "Plan Usage Limits" (default OFF, no behavior change when off).

A Codeman-managed Claude statusLine exporter forwards the rate_limits JSON
to a new auth-exempt POST /api/status-telemetry (same loopback + hook-secret
gate as /api/hook-event); parsed telemetry broadcasts over SSE
session:statusTelemetry to a header chip (amber >=80%, red >=95%, reset
times on hover). The exporter prints the same summary back as the
in-terminal footer (print-through).

- src/usage-telemetry.ts: pure parser/formatter (epoch-sec -> ms, clamp,
  change signature) + test/usage-telemetry.test.ts
- hooks-config.ts: generateStatusLineCommand + applyStatusLineConfig
  (add/remove; never clobbers a user's own statusLine)
- session-routes.ts: inject gate (Claude-only, Codeman-managed cases),
  driven by create-payload statusLineTelemetry (session-ui.js)
- schemas.ts: StatusTelemetrySchema + showPlanUsageLimits + payload field
- frontend: header chip, applyHeaderVisibilitySettings toggle,
  renderIndexHtml strip, _onSessionStatusTelemetry handler

Schema empirically confirmed against Claude Code 2.1.177 (Claude Max):
only five_hour/seven_day windows exist (no Opus-weekly field); rate_limits
is absent before the first API response and for non-subscriber auth. Design
+ verification method in docs/usage-limits-display-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 05:00:28 +02:00
arkonandClaude Fable 5 0809f59f0f chore: version packages — Codeman 1.0.0
Bumps aicodeman 0.9.14 → 1.0.0 (theme skins + first stable release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 23:30:34 +02:00
arkonandClaude Fable 5 eda95adaa9 feat(ui): theme skins — OG Codeman, Daylight Green, Daylight Blue
Add a per-device skin switcher in App Settings → Display:
- Three skins via html[data-skin]: og (original Codeman look),
  daylight-green, and daylight-blue (new default). Per-skin CSS-variable
  token blocks; the v1.0 "Carbon Aurora" component polish is scoped to
  non-og skins and parameterized so green/blue differ only by token values.
- Self-hosted Manrope (UI) + JetBrains Mono (terminal) variable fonts,
  served from /fonts (no external CDN, CSP-safe via font-src 'self').
- Per-skin xterm terminal theme with live re-theming of open terminals on
  skin change; skin-aware --term-bg so the terminal background fills cleanly
  (fixes the variable-height gap above the toolbar).
- Pre-paint inline script applies the saved skin before first paint (no
  flash); persisted per-device in localStorage + the settings blob, and
  kept out of the server settings payload (device-local).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 23:21:47 +02:00
Teigen 41a209e96d fix(cjk): hide CJK textarea on welcome screen and fix vertical centering
- Guard `_updateCjkInputState()` with `activeSessionId` check so the
  `position: fixed` CJK textarea doesn't float over the welcome overlay
- Call `_updateCjkInputState()` in `showWelcome()`/`hideWelcome()` to
  sync CJK visibility on session enter/leave
- Add `padding: 12px 10px` to `.cjk-input-visible textarea` for proper
  vertical centering of input text
2026-06-13 18:49:57 +08:00
Teigen 7102fdb23a fix(mobile): restore response-viewer eye button on phones
The header-right tray (02fa3f3) was reworked into a position:fixed
collapsible panel with a hamburger toggle, but the toggle button, its
JS, and CSS were later reverted on master while the container kept a
static `mobile-collapsed` class. With no expand mechanism left, the
header-right stayed display:none on mobile, so the response-viewer eye
icon was unreachable even with "Response Viewer" enabled — desktop was
fine because the media-query rule doesn't apply there.

Restore the simple inline always-visible header-right layout (dev's
known-good state). The showResponseViewer setting still controls the
eye's --hidden marker class.

Verified on iPhone viewport: eye visible (26x26) with setting on, hidden
with setting off, tap opens the viewer; desktop eye unaffected.
2026-06-13 18:49:11 +08:00
Aamer Akhter 49c92e4723 COD-38 document attachment previews + thumbnails (attachment cards)
Builds on the COD-37 registry: surfaces detected/registered attachments as
dismissible cards with a first-page thumbnail and an inline preview — the
consumer the registry PR deliberately deferred.

Backend:
- document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via
  pdftoppm; Office via the preview cache).
- document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice
  / PowerShell COM), in-flight dedup, multi-converter fallback.
- file-routes: serveConvertedPreview / serveThumbnail + four routes —
  GET .../attachments/:id/preview, .../thumbnail and the workspace-path
  file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe
  resolveServableAttachmentPath, so previews stream the freshly-resolved path.
- server: enrich detected attachment events with a thumbnail route.
- image-watcher: .png now routes to attachment:detected — this PR adds the card
  consumer, so the screenshot popup is no longer its only handler.

Frontend:
- panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all,
  per-session cleanup) plus a 3-arg openFilePreview that renders registered
  attachments inline (image/PDF) or via the server-converted PDF (docx/pptx).
- app.js: wire attachment:detected -> _onAttachmentDetected and card state.
- styles: attachment-card + stack styling.

Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer +
preview-cache unit tests pass; full test:ci green (2861 passed); card render +
preview overlay + dismiss verified in-browser.
2026-06-12 09:09:14 -04:00
Ark0N 3f2c23cb0f Merge pull request #119 from aakhter/pr/cod-37-attachments
Add server-side attachment pipeline (registry, magic-link, path guard)

Review fixes (2767e80): force-confine the terminal magic-link scan path to the
session workspace (closes a prompt-injectable arbitrary host-file read primitive
that broadcast over SSE); keep PNG on the image-popup path (the attachment UI
consumer is out of scope, so rerouting it broke the screenshot popup); serve the
re-resolved path (TOCTOU); 50MB raw cap; per-session registry cap; CLI .env via
dataPath(). Documented in security-architecture.md.
2026-06-11 10:32:34 +02:00
arkon f7ce8e4767 fix(attachments): harden registry + close magic-link injection vector
Security (MAJOR): the terminal-output codeman://attach scanner registered any
matching path server-side with no user confirmation and broadcast the rawUrl
over SSE. Terminal output is attacker-influenceable (a prompt-injected session
can print an arbitrary path), so on the default no-auth deployment this was an
arbitrary host-file (png/pdf/docx/pptx/md/txt) read primitive reachable by any
SSE client. Magic-link registration is now force-confined to the session
workspace (forceWorkspaceConfinement) regardless of the global confine setting;
deliberate cross-workspace attach still works through the explicit,
Origin-guarded POST /attachments route and 'codeman attach' (which POSTs
directly inside a managed session). Documented in security-architecture.md.

Regression (MAJOR): .png was rerouted from the image-popup path to
attachment:detected, which has no frontend consumer — silently breaking the
dropped/pasted-screenshot popup. PNG stays on image:detected; only pdf/docx/pptx
(which never had a popup) emit attachment:detected.

Also:
- raw route streams the freshly-resolved path, not the stored one, so a
  post-registration symlink swap can't redirect the stream (TOCTOU).
- 50MB cap on the attachment raw route, matching file-raw / download.
- per-session attachment registry cap (200) to bound the POST path.
- CLI reads creds via dataPath('.env'), honoring CODEMAN_INSTANCE.

Tests: forced-confinement reject/allow cases; PNG popup-path assertions updated.
2026-06-11 10:27:09 +02:00
Aamer Akhter f1c64994ad COD-37 add server-side attachment pipeline (registry, magic-link, path guard)
Adds the foundation for serving local files to the browser as live external
attachments with a stable id, so requests never carry arbitrary absolute paths.

- attachment-registry: in-memory, session-scoped registry. registerExternalAttachment
  validates an absolute path, resolves symlinks, enforces the path guard, and mints
  an `att_<uuid>` id; records are cleared when the session is removed.
- attachment path guard: a configurable blocklist (secret locations + /root,/etc
  trees, extendable via attachmentBlockedPaths / CODEMAN_ATTACHMENT_BLOCKED_PATHS)
  plus an optional, default-off workspace-confinement mode. Shares one
  sensitive-path blocklist (web/sensitive-path.ts) with /api/download, which is
  refactored to use the extracted module instead of an inline copy.
- terminal magic links: the session scans output for codeman://attach?path=... and
  emits `attachmentRequested`; the web server registers the file and broadcasts an
  `attachment:detected` SSE event. `codeman attach <path>` (CLI) prints the magic
  link or POSTs directly when a session id is known.
- image watcher: detects png/pdf/docx/pptx dropped into a session's working dir and
  emits `attachment:detected`.
- routes: POST /api/sessions/:id/attachments (register) and
  GET /api/sessions/:id/attachments/:attachmentId/raw (serve), both re-checking the
  guard before streaming.

Document previews/thumbnails and the attachment-history drawer build on this
foundation and land separately.

Verified: tsc --noEmit, lint, format, frontend-syntax, full test:ci (2846 passed),
and a server boot smoke (/api/status 200).
2026-06-11 10:27:09 +02:00
Ark0N 12c8e080c1 Merge pull request #118 from aakhter/pr/cod-81-snapshot
feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)

Review fixes (9893a7f): bounded/hardened xterm snapshot persistence — shell-session skip, true LRU eviction, localStorage quota-deadlock fix with evict-and-retry, OSC-strip regex tightened.
2026-06-11 10:22:59 +02:00
arkon 9893a7f64a fix(terminal): bound + harden xterm snapshot persistence
- Skip snapshot save for shell sessions (restore is gated on mode!=='shell',
  so they only burned a serialize() + cache slot + localStorage quota).
- In-memory cache: delete-before-set so eviction is true LRU, not FIFO that
  could drop the most-recently-used session.
- localStorage: extract _persistXtermSnapshot — evict to a fixed key budget
  regardless of session liveness (the old prune only dropped dead keys, so
  >10 live sessions at the 20-session target deadlocked the quota) and
  evict-and-retry on quota errors (the old prune ran only after a successful
  setItem, so a full quota permanently disabled persistence).
- Tighten the OSC-strip regex in _isUsableXtermSnapshot to stop at ST.
- Update the structural test's usability-gate assertion to not depend on a
  fixed byte window.
2026-06-11 10:16:47 +02:00
Aamer Akhter 5b2da424a1 feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)
Switching away from a session and back replayed only the server's byte
history. For TUI modes (codex especially) that shows just the latest
repaint — the idle banner — because the TUI drops earlier conversation
from its current frame. This restores the actual on-screen view.

Two complementary mechanisms:

- Client: load xterm's SerializeAddon and snapshot the rendered state
  (viewport + scrollback + colors) per session on switch-away, restoring
  it for an instant first paint on switch-back. The snapshot is only the
  first paint — the canonical /terminal frame is still fetched and
  reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots
  are LRU-bounded in memory (<=20) and persisted to localStorage
  (<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard.

- Server: GET /api/sessions/:id/terminal prepends the live tmux pane
  buffer (via the existing captureActivePaneBuffer) ahead of the byte
  history, cleared between, so replay reflects the current frame.

Also fix formatPaneSnapshot dropping the rightmost column of every
captured row: it painted to cols - 1 out of caution about last-column
autowrap, but every row is followed by an absolute cursor-position CSI
that cancels xterm's pending-wrap, so painting the full width is safe.

The SerializeAddon is built from @xterm/addon-serialize (new dependency)
into the vendor bundle by postinstall.js (dev) and build.mjs (prod),
matching how the other xterm addons are vendored.
2026-06-10 19:56:30 -04:00
Ark0N aa84447899 Update README to include 'Terminal' in description 2026-06-11 00:38:34 +02:00
Ark0N a0e1a2e33b Update README.md 2026-06-11 00:36:51 +02:00
arkonandClaude Fable 5 6da22f0db0 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:06:12 +02:00
Ark0N dc9c4b3bda Merge pull request #117 from aakhter/pr/cod-86-codex-frontend
fix(codex): smaller first-frame write budget + scroll-up grace for codex
2026-06-10 22:59:40 +02:00
Ark0N 1cf5c8c8ad Merge pull request #116 from aakhter/pr/cod-35-codex-polish
fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
2026-06-10 22:52:02 +02:00
arkonandClaude Fable 5 7eda39e7f7 fix(codex): reassemble chunk-split sequences before the strip; mouse parity on replay
Review fixes:

- Hold back a trailing partial CSI (digit-only intro, ≤7 chars) in
  _handleTerminalOutput and prepend it to the next chunk. PTY chunk
  boundaries are arbitrary, so '\x1b[?1049h' can arrive as '\x1b[?104' +
  '9h' — the per-chunk strip misses it, xterm obeys the reassembled toggle,
  and (with the matching ?1049l stripped) stays stuck in the scrollback-less
  alt buffer until the next replay. Complete sequences are never held; the
  carry resets with the other buffers in _resetBuffers.

- Replay path now also strips mouse-tracking enables (?1000-?1007), matching
  the live strip: buffers persisted BEFORE the live strip existed can still
  carry them, and a replayed ?1006h re-hijacks the scroll wheel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:47:02 +02:00
Ark0N f0db5f827f Merge pull request #115 from aakhter/pr/cod-78-security
feat(security): hook-event auth secret + tunnel password guard
2026-06-10 22:36:47 +02:00
arkonandClaude Fable 5 aa4e1ce9cf fix(security): deliver the hook secret to hooks + isolate its rate-limit bucket
Review fixes for COD-54:

- Generated hook curl commands now present X-Codeman-Hook-Secret, read from
  the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported
  into every managed session's env by tmux buildEnvExports / the direct-PTY
  env builders). Without this, every local hook 401'd the moment a managed
  tunnel came up — the enforcement existed but nothing presented the secret.
  Path-not-value keeps the secret off command lines and out of config files,
  and running sessions pick up a newly generated secret with no respawn;
  server.start() ensures the file exists up front.

- Hook-secret failures now count into a DEDICATED per-IP bucket
  (hookSecretFailures) instead of the shared authFailures map. Legacy
  (pre-secret) hook configs fire constantly from 127.0.0.1; counting their
  401s against the shared bucket would 429 every cookie-less loopback
  request — locking out the Basic-Auth login path (and, through a tunnel,
  every client, since tunneled traffic also arrives as 127.0.0.1).

- docs/security-architecture.md: secret-gated hook exemption, dedicated
  bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback
  proxies (user-run cloudflared / tailscale serve), which the
  managed-tunnel probe cannot see.

- test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected
  after hook-bucket exhaustion; generated hooks reference the header +
  $CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders
  export the path only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:31:09 +02:00
arkonandClaude Fable 5 b8cb4670dd fix(mobile,respawn-ui): unbury the session-options modal on phones; regroup the Respawn tab
Mobile fixes (user-reported: stuck in Session Options with no way to close):
- Modals now stack at z-index 1300, above the fixed mobile/tablet header
  (z-index 1200) that was burying the modal header and its close button —
  the full-screen modal was undismissable on phones
- Duration presets collapse to one compact 24px row (was a 3-row grid)
- Hide the tab detach (open-in-new-window) button on viewports <=768px

Respawn tab regrouped so its two features read as separate options:
- New green-tinted "Respawn loop" box wraps duration, presets, cycle
  steps, and the status/Enable row — a visual sibling of the blue
  auto-resume box; includes a short explanation of the loop
- Enable/status row moved from the top of the tab to the bottom of the
  box, so it no longer reads as a modal-level confirm button
- Font sizes unified: feature titles match; step checkboxes (2./3.)
  match the step labels (1./4.); "Respawn Cycle" renamed "Cycle Steps"

CLAUDE.md: add usage-limit-patterns.ts to the Session row; app.js ~3.7K

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:37:44 +02:00
arkonandClaude Fable 5 4a33b91107 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:56:24 +02:00
arkonandClaude Fable 5 28b531fa5b revert(session): drop the cross-device needsRefresh buffer reload
The post-takeover/re-assert needsRefresh made multi-client redraws worse
in practice (fragmented mixed-width frames on the phone) — reverted to
the behavior the user verified as good: cross-device reflows rely on
Ink's own redraw, stale scrollback scrolls away with new output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:50:24 +02:00
arkonandClaude Fable 5 68310619a7 feat(session,mobile): auto-resume on usage limit + mobile view fixes
Auto-resume on usage limit ("token pause" control, opt-in checkbox at the
top of the Respawn tab, off by default):
- usage-limit-patterns.ts (new, pure): detects all Claude Code limit
  messages (1.0.x-2.1.x eras incl. "5-hour limit reached - resets 8pm",
  "You've hit your limit - resets 1:40pm (TZ)", weekly date forms, raw
  "usage limit reached|<epoch>") and parses the reset time. Conservative:
  no parseable future reset time, no action.
- SessionAutoOps: arms a timer at reset+2min, sends Esc (dismisses the
  rate-limit dialog) + "continue"; dedups footer redraws, retries every
  5min on stale times, cancels when Claude starts working, persists and
  re-arms across Codeman restarts (SessionState.autoResumeEnabled/At).
- Respawn guard: cycles are blocked while limit-paused so /clear cannot
  wipe the paused conversation (respawnBlocked reason 'usage_limit').
- POST /api/sessions/:id/auto-resume; SSE session:limitPauseScheduled/
  limitResume/limitResumeCancelled; toasts + status line in the modal.
- Respawn tab tidied: single-row prompt fields, merged behavior row.

Mobile fixes (0.9.8 regressions, user-reported):
- Resize arbitration is now activity-based: a desktop sizing claim only
  blocks phone resizes while the desktop typed within 90s
  (Session.DESKTOP_CLAIM_IDLE_MS). Idle desktop -> phone takes the pane;
  next desktop keystroke re-asserts the desktop layout server-side
  (noteDesktopActivity via ws-routes input). Phones re-send dims every
  30s (visible tab only, skipped while the keyboard is open) so attaching
  under a hot claim self-corrects. Fixes the desktop-width-stream-in-
  narrow-xterm soup (mid-word wraps, tmux dot fill, Ink overdraw).
- Cross-device reflows (takeover/re-assert) emit a debounced needsRefresh
  so all clients reload the buffer instead of stacking ghost Ink frames.
- Keyboard accessory/toolbar lift restored: measure keyboardOffset
  against window.innerHeight (layout viewport), not the shrunken .app -
  on iOS the offset computed to 0, leaving both bars hidden behind the
  OS keyboard with a dead gap above.
- Removed the mobile header utility ("three dots") toggle entirely;
  the headerRight tray stays collapsed on small viewports.

Tests: usage-limit-patterns (36), session-auto-resume (21), resize
arbitration (+6), session routes (+4), respawn guard (+2); MockSession
auto-resume/sizing stubs; mobile tabs test updated for toggle removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:41:34 +02:00
Aamer AkhterandSaqeb Akhter fe821fb679 fix(codex): smaller first-frame write budget + scroll-up grace for codex
Two render-polish fixes for codex sessions in the terminal write pipeline:

- flushPendingWrites uses a 32KB first-frame budget for codex (vs 64KB for
  other modes). Codex's TUI emits dense synchronized redraws during
  thinking/high-effort phases; a smaller first frame keeps per-frame
  xterm/WebGL stalls short and avoids multi-second main-thread blocks.

- Sticky-scroll now honours a short grace window after a manual scroll-up
  gesture (USER_SCROLL_STICKY_SUPPRESS_MS = 1500ms). High-frequency codex
  "Working (Ns)" status ticks were snapping the viewport back to the bottom
  while the user tried to read earlier output. The wheel/touch scroll
  handlers record the gesture (_noteTerminalUserScroll); flushPendingWrites
  suppresses the auto-scroll-to-bottom and restores the preserved viewport
  via scrollToLine while the grace window is active.

Adds test/terminal-flush-budget.test.ts (vm-sandbox harness over
terminal-ui.js): codex vs non-codex first-frame budget, buffer-load
ownership, and the scroll-up suppression / viewport restore.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:34:45 -04:00
Aamer AkhterandSaqeb Akhter d7606366a2 fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
Codex's TUI emits alternate-screen toggles (DECSET/DECRST 47/1047/1049),
scrollback-erase (CSI 3 J), and mouse-tracking enables (?1000-1007) during
startup and on every repaint. xterm.js obeys them: it switches to the
scrollback-less alternate buffer, wipes saved lines, and forwards the scroll
wheel to codex — so the user's conversation history both disappears and
becomes unreachable on each tab switch / pane refresh.

Strip these sequences in two places, leaving the visible-viewport erases
(2J / J) intact so codex can still repaint its own rows:

- Session._handleTerminalOutput: filter the live SSE/WS stream and the
  persisted terminal buffer at the source, for mode === 'codex'.
- GET /api/sessions/:id/terminal: apply the same strip to the replayed
  buffer (ALT_SCREEN_TOGGLE_PATTERN / ERASE_SCROLLBACK_PATTERN) so a
  tab-switch replay keeps full scrollback.

Adds test/codex-terminal-output.test.ts covering the strip (alt-screen and
3J removed, 2J/J preserved, Ctrl+L redraws preserved) and confirming codex
output passes through without Ink row-repair mangling.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:18:45 -04:00
Aamer Akhter 42f0b28c75 feat(security): hook-event auth secret + tunnel password guard
Two hardening fixes for the public-tunnel exposure path (COD-54 / COD-55).

COD-54 — gate the /api/hook-event localhost bypass when a tunnel is up:
`cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
loopback origin, so a tunneled hook request arrives with req.ip === 127.0.0.1
and the old bare-localhost bypass would pass it unauthenticated. Now:
- tunnel running  → bypass requires a shared per-instance hook secret
  (X-Codeman-Hook-Secret header; constant-time compare) + per-IP rate limiting
- tunnel not running (loopback-only, the normal case) → unchanged, so
  already-deployed credential-less hooks keep working.
New src/config/hook-secret.ts; auth middleware takes a getTunnelRunning probe
(wired from server.ts via tunnelManager.isRunning()).

COD-55 — refuse starting the Cloudflare tunnel without auth:
enabling the tunnel publishes full terminal control to a public URL; with no
CODEMAN_PASSWORD the auth middleware is inactive and the bind guard never trips
(tunnel binds loopback). PUT /api/settings now refuses tunnelEnabled:true with a
403 (before persisting) unless CODEMAN_PASSWORD is set or
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 is acknowledged. New
isUnauthenticatedNetworkAcknowledged() in network-auth-policy; settings-ui
surfaces the refusal as an error toast and reverts the toggle.

Scope: the always-on CSRF/Origin guard, Host-header allowlist, and
network-auth-policy itself are already upstream (#113) and not re-proposed here.

Verification: tsc, eslint, prettier, check:frontend-syntax clean; full test:ci
green (2723 passed), incl. test/cod54-hook-event-auth and
test/routes/system-routes-tunnel-guard.
2026-06-10 12:25:26 -04:00
arkonandClaude Fable 5 055f18fb66 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:20:28 +02:00
arkonandClaude Fable 5 cf2a7f54bf docs(readme): final header tagline — One Dashboard • Any Device (en + zh)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:09:22 +02:00
arkonandClaude Fable 5 beeec63f72 fix(terminal): linear-time link-provider regex; always allow blob workers in CSP
cmdPattern's empty-matchable unbounded arg group backtracked exponentially
on wrapped heredoc/table lines — hovering one froze the tab for minutes.
Non-empty tokens + bounded reps make it O(n); regression test extracts the
shipped patterns and pins timing on the real killer shapes.

worker-src 'self' blob: is now unconditional so terminal-ui's _safeYield
tick worker (throttling escape) isn't CSP-blocked on non-gesture installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:06:45 +02:00
arkonandClaude Fable 5 fad32eeaab chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:16:13 +02:00
arkonandClaude Fable 5 c1458d8ab8 feat(self-update): launchd-daemon supervisor — rootless restart on headless Macs
A KeepAlive system-level LaunchDaemon (the right setup for headless Macs,
where no GUI login means LaunchAgents never start) is now detected as
supervisor 'launchd-daemon': the updater kills the server PID (passed via
--server-pid) and launchd respawns it on the new dist/ — no root needed.
Detection requires the daemon plist to be bootstrapped AND KeepAlive=true.

Also: on boot, a 'completed-needs-manual-restart' status auto-completes
when the running version matches the staged target, so the stale
'restart Codeman to apply' instruction no longer lingers in the UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:08:36 +02:00
arkonandClaude Fable 5 0be3d09603 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:48:32 +02:00
arkonandClaude Fable 5 96035ffa1f feat(settings): Claude Model picker for new sessions; Fable 5 in model dropdowns
App Settings → Claude CLI gains a Claude Model select (claudeModel setting)
that pins the model for new Claude sessions via the case's
.claude/settings.local.json, taking precedence over the 1M Opus toggle.
Fable 5 added to the orchestrator default/phase model dropdowns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:47:05 +02:00
arkonandClaude Opus 4.8 7dd7614760 docs+test: document Codex run mode in CLAUDE.md; make tests immune to CODEMAN_GESTURE
CLAUDE.md: tech-stack, envOverrides, and prefix-discipline sections now
cover the Codex (OpenAI CLI) run mode merged in PR #114 (SessionMode
'codex', codex-cli-resolver, CODEX_* allowlist).

test/setup.ts: strip CODEMAN_GESTURE like the auth vars — when the
shell exports it, renderIndexHtml injects the gesture-availability
flag and test/server-index-title.test.ts byte-identity assertions fail
(1 spurious failure in an otherwise-green local test:ci sweep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:23:57 +02:00
Ark0N 9c986b2869 Merge pull request #114 from aakhter/pr/cod-34-codex
feat(codex): add Codex (OpenAI CLI) run-mode foundation
2026-06-10 16:22:52 +02:00
arkonandClaude Opus 4.8 8c7a9781fa fix(codex): review fixes — envelope handling, mode guards, UI parity
Blocker: runCodex read raw response shapes, but the global
preSerialization hook (server.ts) wraps every payload in the
{ success, data } envelope — status.available was always undefined, so
the UI unconditionally printed "Codex CLI not found" and could never
start a session; the created session was also never auto-selected
(data.sessionId vs data.data.sessionId). Fixed both reads to match
runOpenCode, and updated the test mocks to the real wire shape (plus a
selectSession assertion) so envelope drift fails the test.

Guard parity: export isExternalCliMode() from session.ts and use it in
the ralph-config guard, all three respawn guards, and the six restore/
setup guards in server.ts that previously only excluded 'opencode' —
codex sessions could otherwise get a Ralph tracker or respawn
controller attached (idle detection is Claude-specific and output-
silence respawn cycling would misfire on a quiet codex TUI).

UI parity: cx tab badge, "Kill Tmux & Codex" dialog title, and the
missing CSS (.run-mode-dot.codex, .tab-mode.codex, .mode-codex button
colors — purple) so the Codex menu dot is no longer invisible. Removed
the dead object-literal runMode getter that Object.assign flattens
(superseded by the defineProperty accessor this PR adds).

Verified end-to-end on an isolated instance with a stub codex binary:
10/10 Playwright checks (menu/dot/label/button styling, session
created + auto-selected, cx badge, TUI output streamed, ralph+respawn
guards reject codex) and --dangerously-bypass-approvals-and-sandbox
+ --model observed on the spawned command line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:16:20 +02:00
Aamer AkhterandSaqeb Akhter 70378315da feat(codex): add Codex (OpenAI CLI) run-mode foundation
Add Codex as a first-class session mode alongside Claude/Shell/OpenCode.

- codex-cli-resolver: locate the `codex` binary and augment PATH (mirrors the
  OpenCode resolver)
- SessionMode 'codex' + CodexConfig (model, resumeSessionId, dangerouslyBypass,
  renderMode); persisted in SessionState and threaded through CreateSession/
  RespawnPane options
- schema validation: CodexConfigSchema, CODEX_ env-var prefix allowlist, mode
  enums on create/quick-start, codexDangerouslyBypassApprovals setting
- tmux launch: buildCodexCommand, setenv for OPENAI_API_KEY/CODEX_* (keeps
  secrets out of ps), truecolor COLORTERM, codex PATH resolution
- session + routes: availability check (clear install hint), config passthrough,
  tmux-required guard; Codex skips Claude-only parsers (Ralph/respawn/token)
- run-mode UI: "Run CX" selector option + dedicated Codex CLI settings tab with
  the bypass-approvals toggle; GET /api/codex/status

Scope: foundation only. Codex terminal redraw handling and xterm snapshot/replay
are intentionally excluded and tracked separately.

Verification: tsc --noEmit, eslint, prettier --check, check:frontend-syntax all
clean; full test:ci suite green (2712 passed, 0 failed); server boot smoke OK.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 09:34:52 -04:00
arkonandClaude Opus 4.8 f8b2a2a347 docs: document the response-viewer (eye) button visibility toggle in CLAUDE.md
CLAUDE.md audit against current tree: all commands, counts, and
architecture claims verified accurate; the only drift was the new
showResponseViewer toggle (8a995cb) and its hidden-by-default flip
(dd44976), now covered in the Frontend section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:52:18 +02:00
arkonandClaude Opus 4.8 dd449765de feat(ui): hide the response-viewer (eye) header button by default
Flip both showResponseViewer fallbacks to false and ship the template
with the hidden marker class so fresh installs never flash the button
before settings apply (enabling it via App Settings -> Display ->
Response Viewer still works live and survives reload).

Verified via Playwright on a fresh instance: 5/5 — hidden + unchecked
by default on desktop, enable shows live + persists, marker class
mirrors the per-device setting on mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:16:47 +02:00
arkonandClaude Opus 4.8 8a995cb9e3 feat(ui): toggle to hide the response-viewer (eye) header button
The eye button (View last response) was the only header control with
no visibility setting. Add App Settings -> Display -> "Response
Viewer" (showResponseViewer, default on, per-device like the other
header toggles; added to the displayKeys no-cross-device-sync set
along with showLifecycleLog, which was missing from it).

Hiding uses a marker class with higher specificity — the base rule is
display:inline-flex !important, so an inline style cannot override it.

Verified via Playwright on a fresh instance: 8/8 — default visible,
hides live on save, persists across reload, server schema accepts the
key, re-enable restores, mobile storage isolated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:10:31 +02:00
arkonandClaude Opus 4.8 e1f611b8fb chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:19:10 +02:00
arkonandClaude Opus 4.8 cb978fb178 fix(ui): monitor panel off by default; unbreak task-badge open when hidden
Fresh desktop installs slid the Monitor panel open at startup — the two
showMonitor fallbacks defaulted to true (mobile already defaulted false
via getDefaultSettings). Default it to false everywhere; users opt in
via App Settings -> Show Monitor.

Also fix toggleMonitorPanel(): applyMonitorVisibility() leaves inline
display:none when the setting is off, so the session-tab task badge
toggled the open class invisibly (already broken on mobile). Clear the
inline display when opening so transient opens work.

Local echo defaults audited, unchanged: off on desktop, on for touch
(?? MobileDetection.isTouchDevice()), stored per-device and never
server-synced.

Verified on a fresh isolated instance (desktop + iPhone 13 emulation):
14/14 checks — panel closed + checkbox unchecked on both device
classes, local echo desktop-off/mobile-on, separate storage keys,
badge open works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:12:19 +02:00
arkonandClaude Opus 4.8 1586d32e45 feat(mobile): add Esc button to the simple keyboard accessory bar
The default (simple) accessory bar above the mobile keyboard had no way
to send Escape — the esc action only existed in the opt-in extended
layout. Add the Esc button next to the paste button; the send/refocus
handlers already covered the action.

Verified via Playwright (iPhone 13 emulation): renders next to paste,
sends \x1b to /api/sessions/:id/input, bar still fits 390px without
scrolling, mode round-trips intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:53:42 +02:00
arkonandClaude Opus 4.8 0b29e0e74f docs: sync CLAUDE.md with post-#113 reality
- CI section: document the test job (npm run test:ci via vitest.ci.config.ts)
  and check:frontend-syntax — the "unit suite is excluded" claim was stale
- Testing: rewrite rationale (bare npm test fails on browser suites, not tmux)
  and safety model (TmuxManager in-memory mock under VITEST; the
  registerTestTmuxSession/snapshot mechanism no longer exists)
- API Routes: add the ApiResponse envelope and /api/v1 alias contract
- Minor: app.js ~3.6K lines, codeman bin alias, new command-table rows,
  config-barrel note, generated/gitignored dirs section

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:44:27 +02:00
Ark0N e0ddbb147b Merge pull request #111 from aakhter/pr/cod-33-mobile
fix(mobile): terminal and layout fixes for touch devices
2026-06-10 08:51:30 +02:00
arkonandClaude Opus 4.8 7a39fd9a77 fix(mobile): close audit findings — desktop focus, claim wiring, CJK setting, ESC passthrough
Adversarial post-rebase audit (11 agents) confirmed four real issues;
all fixed:

- Desktop tab clicks stopped focusing the terminal: handleSessionTabClick
  passed preserveKeyboard:false on desktop (KeyboardHandler.keyboardVisible
  is mobile-only state) and selectSession's ternary mapped explicit false
  to 'never focus', skipping the gesture-stack focus master relies on.
  Focus policy now lives solely in _shouldFocusTerminalForTabSwitch()
  (desktop: always; touch: only while the keyboard is open).

- Desktop sizing claims were almost never registered: selectSession's
  resizes run before _connectWs, so they went over HTTP (which never
  claims), leaving the arbitration inert in the canonical desktop+phone
  scenario. ws.onopen now sends a typed resize over the fresh socket —
  registering the claim and syncing PTY dims after (re)connects.

- throttledResize (the main window-resize path) sent untyped HTTP
  resizes: a rotating phone bypassed a desktop claim, and a desktop
  narrowing past the tablet breakpoint never released its stale claim.
  It now sends typed resizes, WS-first, like sendResize.

- The cjkInputEnabled App Settings toggle was silently ignored on touch
  phones/tablets (composer only reachable via the server inputCjkForm
  override, while the checkbox stayed visible and saveable). The user
  setting is honored everywhere again; mobile keeps native-input-by-
  default via the cjkInputEnabled:false mobile default.

- _handleCjkInput appended multi-byte ESC sequences (hardware-keyboard
  arrows/Home/End on the composer) to local-echo pending text, typing
  raw ESC bytes into the prompt on Enter; they are now forwarded to the
  PTY like the onData path. Its backspace path also syncs the
  per-session flushed Maps the way onData does, so tab-switch restore
  no longer resurrects deleted characters.

Defensive: Session.stop() clears desktop sizing claims (a hung client's
socket close can lag teardown by a ping cycle), and the claims docblock
documents the WS-only tradeoff explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 04:31:45 +02:00
arkonandClaude Opus 4.8 e77df131b8 fix(mobile): address review blockers on the touch-device change set
Review follow-ups on PR #111 (rebased onto master post-#112/#113):

Resize arbitration redesigned (review blocker 2): the previous
'cols < _ptyCols' guard froze a mobile-only session's PTY at the spawn
default — narrow phones rendered clipped and could never re-fit. The
guard now uses connection-scoped desktop sizing claims instead:
ws-routes registers a claim on a desktop-typed resize and releases it
on socket close (or when the same connection later reports a small
viewport), and Session.resize() ignores mobile/tablet resizes only
while at least one desktop connection holds a claim. A phone alone
fully controls its size (shrink, rows-only shrink, re-grow); a phone
glancing at a desktop-driven session can no longer reflow it.
mobile-handlers' keyboard open/close resize now declares its viewport
type so it participates in arbitration. Tests rewritten to cover
mobile-only shrink/rows-only/re-grow, claim/release lifecycle, multi-
claim behavior, and untyped legacy resizes; ws-routes test covers the
claim lifecycle over a real socket.

Solo/detached header restored (review blocker 3): index.html had
removed #soloSessionTitle and #soloRedockBtn, which _applySoloMode
still references — every detached window hit a null deref. Both are
back alongside the new mobile utility toggle.

Desktop leak fixed (review should-fix): .mobile-header-utility-toggle
had no rule outside the <=768px media queries, so the raw button
rendered on desktop. styles.css now hides it by default; the mobile/
tablet queries re-enable it.

Visual-regression baselines reverted to master (review should-fix):
the 18 contributor-machine PNGs are environment-specific (8 of the
behavioral tests already report environment-sensitive failures across
machines); re-baseline deliberately on the canonical machine instead.
The 24 behavioral keyboard/layout/tabs tests are kept as-is.

AGENTS.md trimmed to a pointer at CLAUDE.md (review should-fix) to
avoid drift between duplicated guidance.

Also dropped a dead getAttachmentHistoryForPersist stub (codex-branch
residue — no such method exists in src/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 04:07:26 +02:00
Aamer AkhterandSaqeb Akhter 02fa3f30f5 fix(mobile): terminal and layout fixes for touch devices
Mobile-focused fixes for the web UI: keyboard-accessory layout and
overlap, native input visibility above the keyboard, CJK input handling,
terminal touch scrolling, tab-menu tap targets, mic-recording glow
containment, and mobile resize/keyboard-state handling on tab switch,
plus mobile visual-regression test coverage and snapshots.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 03:47:26 +02:00
Ark0N 272f0d13ad Merge pull request #113 from Ark0N/v1-readiness-hardening
v1.0 readiness: governance docs, CI test gate, and security hardening (M1/M7)
2026-06-10 03:46:27 +02:00
arkonandClaude Opus 4.8 c29475ed10 fix(api): close contract gaps found by post-merge adversarial audit
A 15-agent audit of the merged tree confirmed 9 envelope/contract bugs;
all fixed here, with live-server contract tests added:

Blockers (fresh-install quick start broken):
- session-ui.js runClaude/runShell unwrapped .data from the /api/cases/:name
  404 error envelope (which has no data key), so a not-yet-created case threw
  TypeError instead of triggering the auto-create fallback. Now '?.data ?? {}'.

Contract violations on the new stable surface:
- Unknown /api routes returned HTTP 404 with {success:true,...} (Fastify's
  default not-found payload was wrapped by the envelope hook). Added a
  setNotFoundHandler returning the standard error envelope for /api paths.
- POST /api/events/subscribe 400 body became {success:true,data:{error}};
  now createErrorResponse(INVALID_INPUT).
- POST /api/clipboard validation error lacked errorCode and shipped HTTP 200;
  now createErrorResponse(INVALID_INPUT) -> 400.
- POST /api/run catch path returned bare {success:false,sessionId,error}
  (HTTP 200, no errorCode); now OPERATION_FAILED envelope -> 422 with the
  dead session id in the message.
- DELETE tail-file/:streamId returned {success: closed}, colliding with the
  envelope discriminator; now returns {closed}.

Dead/regressed UI paths:
- Plan history modal could never open: route returned the bare history array
  under data while the frontend read data.data.history/currentVersion. Route
  now returns {history, currentVersion}; modal task count fixed to stats.total.
- Self-update error toast read j.error.message from the string-typed envelope
  error, always falling back to the generic message; now reads the string.

Cleanup:
- Removed the stale QuickStartResponse type (unreferenced; documented the
  pre-envelope shape and invited success-key collisions).

Tests: new test/http-contract.test.ts boots a real WebServer (port 3168) and
pins the envelope, /api/v1 alias, error statuses, and the /api 404 shape —
the route-test harness does not install the server-level hook, so these need
the live server. Updated file-routes/plan-routes/scheduled-runs tests to the
fixed shapes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 03:41:04 +02:00
arkonandClaude Opus 4.8 732463b36e fix(web): loadQuickStartCases expected enveloped settings from the already-unwrapped shared settingsPromise
app.js resolves settingsPromise to the unwrapped settings object
(env?.data ?? null), matching loadAppSettingsFromServer. The
loadQuickStartCases consumer still read settings.data.lastUsedCase,
which silently dropped the last-used-case preselection; its fallback
fetch also missed the envelope unwrap. Align both with the unwrapped
shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 03:19:51 +02:00
arkonandClaude Opus 4.8 19d7167fa2 Merge master (PR #112 terminal pane-buffer rework) into v1-readiness-hardening
Conflict in src/web/public/app.js selectSession: combined #112's
_clearTerminalLoadState cleanup on stale select with #113's
{success,data} envelope unwrap of the terminal fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 03:17:12 +02:00
Ark0N 227495a9bd Merge pull request #112 from aakhter/pr/cod-32-terminal
feat(terminal): tmux pane-buffer primitives and session/render reliability
2026-06-10 03:09:07 +02:00
arkonandClaude Opus 4.8 b75181b725 fix(terminal): address re-review findings on the pane-buffer rework
Follow-up to the PR #112 re-review (all six prior blockers were already
resolved; these are new issues the rework introduced):

- app.js: define the missing `_scheduleTerminalRepaint()` helper. It was
  called from both WebGL-fallback paths (onContextLoss + long-task trip)
  but defined nowhere, so each fallback threw `TypeError` and lost the
  post-fallback repaint, leaving a stale/blank terminal. Implemented as an
  rAF-debounced full refresh (matches the old inline `terminal.refresh`).
- app.js: clear terminal load-state on the two post-write stale-select
  early-returns (cached-buffer + rewrite branches), matching the other
  four checks. Switching away from a mid-loading tab no longer leaks a
  permanent `.tab-loading` spinner / `aria-busy=true`.
- terminal-ui.js + app.js: gate the post-resize TUI-redraw settle on an
  actual dimension change. `sendResize` now returns whether dims changed;
  a same-size tab switch sends no SIGWINCH, so the wait is skipped instead
  of charging a flat tax on every non-shell switch. Literal hoisted to
  `TUI_REDRAW_SETTLE_MS`.
- tmux-manager.ts: `resizeWindow()` uses a non-blocking `exec` instead of
  `execSync` so the interactive WS/HTTP resize path can't stall the
  Fastify event loop on a slow/hung tmux. Sole caller already fire-and-
  forgets the result; test updated to assert the async dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 02:07:26 +02:00
arkonandClaude Opus 4.8 e38e53302b test: de-flake StaleExpirationMap age/TTL timing assertions for CI
The new CI test gate surfaced a pre-existing flaky timing test: 'should return age of entry' asserted age>=50 after a 50ms setTimeout and measured 49ms on a jittery CI runner. Widened the elapsed-time windows (age >=40/<500; remaining TTL >700/<=960) so they tolerate timer jitter. Pre-existing flakiness, unrelated to the API migration. (File was also normalized by prettier per the pre-commit hook.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:38:40 +02:00
arkonandClaude Opus 4.8 458fb81cbe feat(api): establish stable HTTP contract — uniform {success,data} envelope, status codes, /api/v1
Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).

Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).

Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.

Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.

Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:30:43 +02:00
arkonandClaude Opus 4.8 5b3024b327 chore(v1): raise Node floor to >=22 and add codeman bin alias
- engines.node >=18 -> >=22 (Node 18/20 are EOL; CI only tests 22; the start script + systemd unit use NODE_COMPILE_CACHE which needs 22.1+). Updates the README badge and CLAUDE.md requirements to match.
- bin: add a 'codeman' alias alongside 'aicodeman' so 'npm i -g aicodeman' provides the 'codeman' command every doc/symlink references (program.name is already 'codeman'; the published package name stays 'aicodeman').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:16:01 +02:00
Aamer AkhterandSaqeb Akhter 0569f68b86 feat(terminal): tmux pane-buffer primitives and session/render reliability
Mode-agnostic terminal foundation extracted from the downstream branch:
- formatPaneSnapshot: SGR/grapheme-aware tmux pane capture + active-pane
  resolution, with OSC/CSI redraw suppression and the buffer-load owner-token
  race fix on the terminal fetch path
- socket-correct tmux lifecycle: dedicated -L socket and /tmp launch cwd in
  createSession (restores the FUSE/getcwd hardening from #110), and a
  socket-aware re-attach window-size query (avoids the 120x40 flicker)
- inline-rename: commit/cancel state handling clears _activeRename and skips
  the API call on cancel
- selectSession: restored detached-window raise short-circuit

The codex-specific xterm snapshot/replay, the vendored serialize addon, and
the synchronous live pane-capture on the request path are intentionally
excluded: they depend on a 'codex' SessionMode that doesn't exist on master
and are deferred to COD-34 (which introduces that mode). The capture
primitives remain exported for COD-34 to build on.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-09 15:39:03 -04:00
arkonandClaude Opus 4.8 b84438a0aa fix(security): push-endpoint SSRF guard + tmux name validation; document tail-file roots
- M7 (SSRF): add isSafePushEndpoint (https-only; reject internal/loopback/link-local/metadata IPs incl. IPv4-mapped); enforce in PushSubscribeSchema and re-check before webpush.sendNotification. + unit test.
- M1 (command injection): validate tmux session names with isValidMuxName in sessionExists, killSession, and reconcileSessions before they reach a shell call site.
- M5: keep the intentional /var/log + ~/logs log-tail roots (a tested feature) and document the wider read scope in docs/security-architecture.md section 5 instead of dropping it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:02:17 +02:00
arkonandClaude Opus 4.8 d5f91e4cd7 test(ci): run the unit suite in CI + frontend-syntax gate; green pre-existing test debt
- CI: add a 'test' job running the unit suite via config/vitest.ci.config.ts. Excludes browser (Playwright/chromium) and perf tests (timing-flaky), like the existing test/mobile suite. Safe in CI: TmuxManager no-ops shell commands under VITEST (test/setup.ts).
- Add scripts/check-frontend-syntax.mjs (node --check on src/web/public/*.js), wired into the lint job — catches a class of frontend SyntaxError that passes lint today (lint globs only TS).
- Add test/security-regression.test.ts (wired Host/Origin guard, self-update CSRF, CSP/security headers, text/plain raw body, WS anti-CSWSH) + test/sse-registry-parity.test.ts (backend<->frontend SSE registry parity).
- Green pre-existing test debt surfaced by the new gate: stale 'Session not found' asserts -> 'not found' substring; drop tests for removed helpers (isError now internal; createSuccessResponse deleted); file-stream-manager: mock realpathSync + fix stale /tmp assertion; sse-subscription-filter: lifecycle events broadcast to all clients (only terminal stream filtered); session.test.ts: mkdir /tmp/test; skip one interactive-respawn test needing a real PTY (covered by respawn-controller.test.ts).
- Full non-mobile suite verified green locally (2680 passed, 12 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:02:15 +02:00
arkonandClaude Opus 4.8 36bc22a3d5 docs(v1): add SECURITY.md + versioning policy; fix LICENSE and stale overlay gotcha
- Add SECURITY.md: private disclosure path, supported versions, known limitations.
- Add docs/versioning-policy.md defining what 1.0 SemVer covers (CLI + documented env vars are public; HTTP/SSE API, on-disk state, and experimental features are internal/unstable).
- LICENSE: '2024 Claudeman Contributors' -> '2024-2026 Codeman Contributors'.
- CLAUDE.md: fix the stale xterm-zerolag-input 'duplicated in app.js' gotcha (it is single-source now -> gitignored vendor bundle via postinstall.js/build.mjs); add versioning + security pointers; minor /init nav fixes (image-input load order, server.ts marker).
- README: link SECURITY.md + the versioning policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:05:50 +02:00
Ark0N 2952256d65 Update README.md 2026-06-09 14:19:53 +02:00
194 changed files with 19535 additions and 1880 deletions
+34 -3
View File
@@ -31,6 +31,9 @@ jobs:
- name: Lint
run: npm run lint
- name: Frontend JS syntax check
run: npm run check:frontend-syntax
- name: Format check
run: npm run format:check
@@ -60,6 +63,34 @@ jobs:
cat /tmp/boot.log
exit 1
# Note: The test suite is intentionally excluded from CI.
# Tests spawn real tmux sessions and require a full system environment.
# Run tests locally with: npx vitest run test/<file>.test.ts
test:
name: Unit & integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install tmux
run: |
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
- name: Run unit & integration tests
# Excludes the browser-driven mobile suite (test/mobile/**); see config/vitest.ci.config.ts.
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
run: npm run test:ci
# Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI —
# it needs a live server + chromium + environment-specific PNG baselines.
# Run it locally/manually. All other tests run via the `test` job above.
+4
View File
@@ -18,6 +18,10 @@ coverage/
test/e2e/screenshots/current/
test/e2e/screenshots/diffs/
# Mobile visual regression failure artifacts
test/mobile/snapshots/*.actual.png
test/mobile/snapshots/*.diff.png
# Logs
*.log
npm-debug.log*
+16
View File
@@ -0,0 +1,16 @@
# Repository Guidelines
Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) —
project structure, build/test/lint commands, code style, testing safety rules
(never run the full suite inside a managed tmux session), security notes, and
the deployment workflow are all maintained there. Please read it before making
changes, and keep it the single source of truth rather than duplicating
sections here.
Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Targeted tests only: `npm test -- test/<file>.test.ts` (bare `npm test` is unsafe in managed sessions)
- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
+218
View File
@@ -1,5 +1,223 @@
# aicodeman
## 1.1.9
### Patch Changes
- Two welcome-screen tunnel changes:
- **UI (Daylight Blue skin):** the **Cloudflare Tunnel** button is now purple (was orange/yellow), keeping the three welcome buttons visually distinct — Claude blue, Tunnel purple, OpenCode green.
- **Enable a tunnel without `CODEMAN_PASSWORD`, with a warning.** Previously enabling the Cloudflare tunnel with no password set was hard-refused unless you set `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Now you can opt in straight from the browser: clicking the tunnel toggle without a password pops a **security confirm dialog** ("publishes this machine to a public URL with no login — effectively remote code execution; set CODEMAN_PASSWORD instead"), and only on confirm does it enable, sending an explicit per-request `acknowledgeUnauthTunnel:true`. The server logs a loud warning whenever a passwordless public tunnel starts. curl/API/CLI callers are unchanged — still refused unless they set a password, set the env var, or pass `acknowledgeUnauthTunnel:true` — so nothing gets exposed accidentally. The acknowledgment is an action field and is never persisted to settings.json.
## 1.1.8
### Patch Changes
- UI (Daylight Blue skin): give the welcome-screen action buttons distinct colors instead of all reading blue. **Run Claude Code** keeps the blue accent, **Cloudflare Tunnel** now uses Cloudflare's brand orange, and **Run OpenCode** uses an emerald green — so the three are visually distinguishable at a glance. Scoped to the default `daylight-blue` skin only (daylight-green and OG are unchanged), with matching hover/active states and dark ink for contrast. Verified in a real browser: the three buttons compute to blue / orange / green gradients on the welcome overlay.
## 1.1.7
### Patch Changes
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
## 1.1.6
### Patch Changes
- Fix: ultracode floating run windows now pop on a fresh device/browser that loads while a run is already active.
`ultracodeFloatingWindows` syncs from the server (it's a non-display setting), but on a first-time device the SSE `getLightState` run snapshot can seed the run list BEFORE the async settings load resolves — so the floating-window gate read `false` at that instant and skipped any already-active run, leaving the window un-popped until the next ~10s watcher tick. The app now re-runs `syncAllUltracodeFloatingWindows()` once server settings finish loading (in the `loadAppSettingsFromServer().then()` callback), so an in-flight run pops its window immediately. Idempotent: open windows are left as-is, and if the setting is off any premature windows are torn down. Verified end-to-end against a real in-flight run on an isolated instance — a pristine browser (empty localStorage) seeds the setting from the server and pops the active run's window ~0.4s after first paint.
Also corrected a stale `@fileoverview` comment in `ultracode-windows.js` that claimed the floating windows are gated on `showUltracodeAgents`; they are gated on the dedicated `ultracodeFloatingWindows` toggle (only the docked "Ultracode Agents" panel uses `showUltracodeAgents`).
## 1.1.5
### Patch Changes
- Fix: the Ultracode Agents panel's (×) Close button now fully hides the panel.
`closeUltracodeAgentsPanel()` only removed the `open` class, which drops the bottom-docked drawer to its collapsed _peek_ state (the 36px header strip stays visible) rather than closing it — so clicking (×) looked like it did nothing. It now also adds the `hidden` class (`display:none`), mirroring `closeSubagentsPanel()`. It deliberately does NOT flip the `showUltracodeAgents` setting (that also gates the run watcher and floating windows); the header launcher button reopens the panel. Verified in a real browser: after (×) the panel computes `display:none`.
## 1.1.4
### Patch Changes
- Fix: ultracode floating run windows (and the live dock panel) now appear DURING an in-flight Workflow/ultracode run, not only after it finishes.
The Workflow runtime writes the run-state file `…/workflows/wf_<id>.json` only at completion (always a terminal status); while a run is live, its only on-disk state is the sibling `…/subagents/workflows/wf_<id>/` transcript tree. `workflow-run-watcher` previously scanned only the completion file, so it never observed a run until it was already terminal — and the floating-window auto-pop is gated on an ACTIVE run, so it never fired for a live run (the feature was effectively dead for in-flight runs).
The watcher now ALSO scans the `subagents/workflows/wf_<id>/` transcript tree and synthesizes a minimal ACTIVE run (status `running`, agent slots keyed by their `agentId` so the agent-card → live-transcript click still works, `lastActivityAt` from the newest agent/journal mtime, per-agent done/running derived from the run journal's `result` events) when no completion file exists yet. When the run finishes, the real `wf_<id>.json` supersedes the synthesized record (same runId), restoring full phase/token detail and the normal finish → 8s-grace auto-close flow. The watcher stays standalone (it never imports subagent-watcher). Verified end-to-end against a real in-flight run; adds unit coverage for live synthesis, agentId preservation, journal-derived state, empty-dir skipping, and completion-file precedence.
## 1.1.3
### Patch Changes
- Ultracode floating run windows + a dedicated toggle to control them.
- **New: floating ultracode run windows.** When enabled, each active ultracode / Workflow run pops a small draggable window (like the file browser) connected by a glowing line to its originating session tab — the same connector-line idiom as subagent windows. The tab is resolved by matching the run's `sessionUuid` to a session's `claudeSessionId`. The window mirrors the live agent grid (phases, per-agent model / tokens burned / tool calls / state), auto-closes a few seconds after its run finishes, and remembers windows you explicitly dismiss so they don't re-pop. These windows are **additional to** the existing docked "Ultracode Agents" master-detail panel, which is unchanged.
- **New setting "Ultracode Floating Windows"** (App Settings → Display), **default OFF**, independent of the "Ultracode Agents" panel toggle. Either toggle now starts the server-side workflow-run watcher (at boot and on live settings change), so the floating windows work even with the docked panel off.
- Internals: new frontend module `ultracode-windows.js` (load order 15.5); ultracode connector lines are appended into the shared `#connectionLines` SVG within the existing batched read/write reflow pass in `subagent-windows.js`; new `ultracodeFloatingWindows` app-settings key in `schemas.ts`; watcher gating in `server.ts` + `system-routes.ts` now ORs both ultracode toggles.
- Docs: `CLAUDE.md` brought up to date for the 1.1.2 ultracode/workflow-run subsystem (Agents / Frontend / Types / Config inventories, JS load order, a Key Patterns entry) and the new floating-windows feature.
## 1.1.2
### Patch Changes
- Ultracode/Workflow run visualization + subagent discovery fixes.
- **Ultracode / Workflow run visualization** (new, opt-in): App Settings → Display → "Ultracode Agents" (`showUltracodeAgents`, default OFF) adds a master-detail tab that shows ultracode / Workflow-tool runs like Claude Code's "working agents" view — the LEFT pane lists runs and their phases (selectable tasks), the RIGHT pane shows each run's agents with model, live state, tokens burned, and tool calls. Clicking an agent opens its live transcript. Backed by a new standalone workflow-run watcher that reads the per-run state JSON (stripping the heavy embedded script/result/logs so payloads stay small), exposes `GET /api/workflows` and `GET /api/workflows/:runId`, and broadcasts `workflow:run_discovered/updated/removed` SSE events. The header launcher and panel stay hidden until the setting is enabled (the setting is synced across devices, not per-device).
- **Subagent tracking discovery fix**: restored subagent tracking after Claude Code changed the on-disk format from `agent-*.jsonl` to `agent-*.meta.json` (background agents were showing 0). Also discovers workflow-nested subagents under `subagents/workflows/<wf>/` and hardens the meta→transcript upgrade path so an agent re-points to its `.jsonl` transcript once it appears.
- **File viewer**: opens audio, SVG, and other binary files the same way the attachments viewer does.
- **Tooling**: hardened the real-overview screenshot capture script and documented the `deviceScaleFactor` / static-cache gotchas.
## 1.1.1
### Patch Changes
- Six reviewed contributor PRs (all adversarially reviewed and fixed before merge):
- **Markdown sanitizer hardened against mutation-XSS (#126).** The denylist `_sanitizeHtml` is replaced with vendored DOMPurify 3.4.8 (authentic, byte-matched to the official dist) wired via a new `sanitize-html.js` allowlist, with a fail-closed escape fallback. The curated allowlist is genuinely enforced (no `USE_PROFILES` override) so non-markdown tags and svg/math/style/script/event-handler/`javascript:` vectors are stripped while legitimate markdown survives.
- **Hook-event secret now required unconditionally (#127).** The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the per-instance hook secret whether or not a managed tunnel is running, closing the own-loopback-reverse-proxy gap. A self-heal refreshes pre-secret hook configs in existing cases on spawn so password-protected installs don't silently 401 their hooks. No-password loopback installs are unaffected.
- **`codeman doctor` dependency checker (#125).** New `doctor`/`check-deps` command probes Node, the agent CLIs, tmux, and document converters per environment (linux/darwin/win32/wsl), with grouped or `--json` output and a non-zero exit when a required tool is missing. Requires Node 22+, reports `pdftoppm` (used for PDF/Office thumbnails), and validates `--category`.
- **macOS Option / physical-key session shortcuts (#129).** Tab switching matches physical key codes (`e.code`) so Option+1–9 works on macOS layouts that remap Option, plus Option/Alt+`[`/`]` for previous/next session — without leaking escape sequences into the focused terminal.
- **Desktop session tabs auto-wrap to a second row on overflow (#128)** instead of horizontal scrolling (off when the manual two-row layout is pinned; mobile/tablet unchanged), re-evaluated on window resize.
- **CJK input textarea hidden on the welcome screen (#123)** so it no longer floats over the welcome overlay, and re-shown on session entry; vertical centering fixed.
## 1.1.0
### Minor Changes
- **Plan Usage Limits chip (new).** A header chip now shows your live Claude plan usage — the 5-hour and weekly windows as a percentage — parsed from Claude Code's statusLine telemetry (CLI v2.1.80+). It's opt-in via **App Settings → Display → "Plan Usage Limits"** (default OFF). The toggle is **per-device**: turn it on at your desk without it appearing on your phone. Telemetry collection is decoupled from display, so one device's preference never affects another's, and the last-known value replays instantly on reconnect. Distinct from auto-resume (which reacts to the limit _message_) — this proactively shows the live %.
**Attachments.** New attachment history drawer to browse files referenced by a session (COD-39), plus document previews and thumbnails on attachment cards (COD-38). The header **Attachments button is now opt-in** (default OFF) via **App Settings → Display → "Attachments Button"**, per-device like the Response Viewer button.
**Settings & models.** Added Opus 4.6 options to the Claude Model picker. Removed the legacy Token Count / Show Cost header toggles and moved Plan Usage Limits to the top of the Display settings. Slimmed the Skin picker control to match its row.
**Mobile & header polish.** Restored the response-viewer (eye) button on phones; kept the phone header minimal (settings gear + lifecycle log stay in the toolbar). Added two regression guards so header controls can't silently leak onto the mobile header again — a CI-runnable static policy check plus a real-browser E2E test.
## 1.0.0
### Major Changes
- # Codeman 1.0.0 🎉
The first stable release of Codeman — and it comes with a fresh new look.
**New: theme skins.** Codeman now ships a built-in skin switcher (App Settings → Display → Appearance):
- **OG Codeman** — the original look, preserved exactly.
- **Daylight Green** — a fresh emerald-on-slate theme.
- **Daylight Blue** — bright sky-blue on lifted slate (the new default).
Skins apply instantly, persist per device (with a pre-paint script so there's no flash on load), and re-theme any open terminals live. The system is built on `html[data-skin]` design tokens and self-hosted Manrope (UI) + JetBrains Mono (terminal) fonts — no external CDN, CSP-safe.
**1.0.0 milestone.** This marks the start of the stable 1.x line: the CLI, documented environment variables, and the `{ success, data }` HTTP/SSE API envelope follow semantic versioning (see `docs/versioning-policy.md`).
**Thank you to everyone who helped build Codeman.** This release is dedicated to all of our contributors for their work on the project: Ark0N, Aamer Akhter (@aakhter), Tenggan Zhang (@TeigenZhang), zhouyuan / @sunnyzhouy, jaypark, Marco Migozzi, Skúli Arnlaugsson, Aaron Fields, Loïc Sculier, and Noah Waldner (@noahwaldner). 💙
## 0.9.14
### Patch Changes
- Security hardening for the tunnel exposure path, Codex terminal rendering fixes, and a mobile modal fix.
**Security (PR #115, COD-54/COD-55):**
- `/api/hook-event` localhost bypass is now gated while the managed Cloudflare tunnel is running: tunneled traffic arrives with a loopback source IP, so the bypass additionally requires a per-instance shared secret (`X-Codeman-Hook-Secret`, 256-bit, `~/.codeman/hook-secret`, mode 0600). Locally generated hook commands read the secret file at execution time via `$CODEMAN_HOOK_SECRET_FILE` (exported into every managed session's environment), so the value never lands on command lines or in case configs, and running sessions pick up a new secret without respawn. Failed presentations rate-limit in a dedicated per-IP bucket so misfiring legacy hooks can never lock out the Basic-Auth login path. With no tunnel running, behavior is unchanged.
- Enabling the Cloudflare tunnel now **refuses with 403** when no `CODEMAN_PASSWORD` is set (a public tunnel URL with no auth is effectively public RCE), unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly acknowledges the exposure. The settings UI surfaces the refusal as an error toast and reverts the toggle.
**Codex rendering (PRs #116, #117):**
- Alt-screen toggles (`?47/?1047/?1049`), scrollback-erase (`CSI 3 J`), and mouse-tracking enables (`?1000`–`?1007`) are stripped from the Codex byte stream (live + replay), so conversation history survives tab switches and the scroll wheel scrolls the viewport instead of being hijacked. Sequences split across PTY chunk boundaries are reassembled via a small carry before stripping, so a split `?1049h` can no longer trap xterm in the scrollback-less alt buffer.
- Smaller 32KB first-frame write budget for Codex sessions keeps dense synchronized redraws from stalling the renderer; a 1.5s grace window after a manual scroll-up suppresses sticky-scroll so high-frequency `• Working (Ns)` status ticks no longer snap the viewport back to the bottom while reading earlier output.
**Mobile:** session-options modal raised above the fixed mobile/tablet header (z-index 1300 vs 1200) so the close button is reachable on phones; Respawn tab controls regrouped.
**Docs:** security-architecture.md updated for the secret-gated hook bypass (including the external-proxy caveat) and the tunnel password guard; README documents auto-resume on usage limit.
## 0.9.13
### Patch Changes
- Auto-resume on usage limit ("token pause" control) plus a set of mobile-view fixes for regressions introduced in 0.9.8.
**Auto-resume on usage limit** — new opt-in checkbox at the top of the session Respawn tab (off by default). When Claude stops because a usage limit was reached, Codeman parses the reset time from the limit message, waits until the limit lifts (plus a 2-minute safety buffer), then dismisses the rate-limit dialog (Esc) and sends "continue" so the session picks its work back up automatically. All Claude Code message formats from 1.0.x through 2.1.x are recognized ("5-hour limit reached ∙ resets 8pm", "Limit reached · resets 1pm (America/Chicago) · /upgrade…", "You've hit your weekly limit · resets Mon 12:00am", weekly date forms, and the raw API `usage limit reached|<epoch>` form). Still-limited responses re-arm the scheduler (5-minute retry loop); a pending schedule persists across Codeman restarts and re-arms on boot; respawn cycles are blocked while a limit pause is active so the cycle's `/clear` cannot wipe the paused conversation. New endpoint `POST /api/sessions/:id/auto-resume`; new SSE events `session:limitPauseScheduled`, `session:limitResume`, `session:limitResumeCancelled`; toast/notification on pause and resume, plus a live "resumes at HH:MM" status line in the modal. The Respawn tab layout was also tidied: compact single-row Update/Kickstart prompt fields and a merged options row.
**Mobile fixes (0.9.8 regressions)**:
- **Activity-based resize arbitration** — a desktop sizing claim now only blocks a phone's resize while that desktop has actually typed within the last 90 seconds. Previously any connected desktop tab (even one abandoned hours ago) silently discarded the phone's resize with no fallback, leaving the phone rendering a desktop-width stream in a narrow terminal: mid-word wraps, tmux dot-fill rows, overdrawn garbled text, and misplaced keyboard echo. Now an idle desktop yields the pane to the phone, and the next desktop keystroke automatically restores the desktop layout ("whoever is actively using the session wins"). Phones also re-send their dimensions every 30 seconds (visible tab only, skipped while the virtual keyboard is open) so attaching under a momentarily-active desktop self-corrects.
- **Keyboard accessory bar and toolbar restored on iOS** — the lift offset is measured against the layout viewport (`window.innerHeight`) again instead of the keyboard-shrunken app element; on iOS the offset computed to 0, leaving both bars hidden behind the OS keyboard with a dead black gap above it.
- **Removed the mobile header utility ("three dots") toggle** — the header-utilities tray stays collapsed on small viewports.
## 0.9.12
### Patch Changes
- Documentation refresh — README catches up with the Codex run mode, plus a CLAUDE.md correction.
**README (en + zh-CN)**: Codex is now listed as a third supported AI coding CLI everywhere the docs previously said "Claude Code or OpenCode": the install requirement in Quick Start (now "any combination works", linking to the official Codex CLI docs), the Windows/WSL setup note, the renamed **Multi-CLI** feature bullet (env-prefix gating now reads `CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`), the Zod schema-validation security bullet, and the architecture mermaid diagram. The header tagline was also finalized to "Claude Code • OpenCode • Codex — One Dashboard • Any Device" in both languages.
**CLAUDE.md**: fixed a stale "Local packages" line that claimed the xterm-zerolag-input local-echo overlay had a copy embedded in `app.js` — it is single-source in `packages/xterm-zerolag-input/`, bundled to the gitignored vendor file, and only consumed by `app.js`, matching the existing single-source gotcha.
## 0.9.11
### Patch Changes
- Fix a terminal freeze on hover (catastrophic regex backtracking) and a CSP violation that disabled the terminal's anti-throttling worker.
**Tab-freezing hover bug**: the terminal link provider's `cmdPattern` (which turns `tail -f /path`-style text into clickable links) used an empty-matchable, unbounded arg group — `(?:[^\s\/]*\s+)*` — that backtracks exponentially on real Claude output, e.g. wrapped `git commit -m "$(cat <<'EOF'` heredoc lines or aligned table rows. Hovering the mouse over such a line hung the page's main thread for minutes ("page unresponsive"). The pattern now uses non-empty tokens with bounded repetition (linear time); all intended command+path link forms still match. New `test/link-provider-regex.test.ts` extracts the shipped patterns from source and pins linear-time behavior on the killer line shapes.
**Blob worker CSP fix**: `worker-src 'self' blob:` is now always present in the CSP (previously only with `CODEMAN_GESTURE=1`). The terminal's `_safeYield` anti-throttling tick worker is created from a Blob URL and was silently blocked on every install, logging a CSP violation on each page load and disabling the worker leg of the render-yield fallback chain.
## 0.9.10
### Patch Changes
- Self-update now restarts automatically on headless Macs supervised by a system LaunchDaemon.
New `launchd-daemon` supervisor kind: when Codeman runs under a bootstrapped, KeepAlive system-level LaunchDaemon (`/Library/LaunchDaemons/com.codeman.web.plist` — the right setup for headless Macs, where LaunchAgents never start because there is no GUI login), the updater no longer ends with "Update staged — restart Codeman to apply". It restarts rootlessly: the update script kills the server PID (passed via `--server-pid`) and launchd respawns it on the freshly built `dist/`. Detection is conservative — the daemon must be bootstrapped in the system domain AND have `KeepAlive` enabled.
Also fixed: a lingering "restart Codeman to apply" status. After a manual restart of a staged update, boot reconciliation now flips `completed-needs-manual-restart` to `completed` once the running version matches the staged target, so the Updates tab stops showing the stale instruction.
## 0.9.9
### Patch Changes
- Codex (OpenAI CLI) run mode, Claude Model picker, and response-viewer button now opt-in.
**Codex (OpenAI CLI) run mode** (#114): new `codex` session mode alongside Claude Code and OpenCode. Sessions launch the Codex CLI via tmux with secrets injected through `tmux setenv` (`OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` — never on the command line). Supports `--model`, `resume <id>`, and `--dangerously-bypass-approvals-and-sandbox` via the `codexConfig` payload or the new App Settings → Codex CLI tab (`codexDangerouslyBypassApprovals`). Availability surfaced at `GET /api/codex/status` with an install hint when the binary is missing. Frontend gets a "Run CX" run-mode option; Respawn/Ralph options stay Claude-only (session options open on the Summary tab for external-CLI sessions). `CODEX_*` env prefix added to the env-override allowlist.
**Claude Model picker**: App Settings → Claude CLI gains a "Claude Model" select (`claudeModel` setting) that pins the model for new Claude sessions via the case's `.claude/settings.local.json` — e.g. Fable 5 (1M context), Fable 5, Opus (1M), Opus, Sonnet, Haiku. It takes precedence over the legacy 1M Opus Context toggle. Fable 5 also added to the orchestrator default/phase model dropdowns.
**Response-viewer (eye) header button is now hidden by default** — existing users who relied on it can re-enable it under App Settings → Display → Response Viewer (`showResponseViewer`, per-device setting). A new Display toggle controls its visibility.
Also: tests made immune to a set `CODEMAN_GESTURE` env var; CLAUDE.md documents the Codex run mode and the eye-button toggle.
## 0.9.8
### Patch Changes
- Stable HTTP contract, terminal pane-buffer rework, mobile/touch fixes, and fresh-install default cleanups.
**API / v1 readiness (PR #113)**
- Stable HTTP contract: uniform `{success, data}` / `{success: false, error, errorCode}` response envelope across all ~134 handlers, correct HTTP status codes, and a versioned `/api/v1/*` alias of `/api/*`
- Post-merge adversarial audit closed 9 contract gaps (envelope/status-code stragglers), incl. `loadQuickStartCases` double-unwrap
- Node.js floor raised to >=22; `codeman` bin alias installed alongside `aicodeman`
- Security hardening: SSRF guard on the push endpoint, tmux session-name validation, documented tail-file roots
- Governance: SECURITY.md and a SemVer versioning policy (docs/versioning-policy.md)
- CI now runs the full unit/integration suite (vitest.ci.config.ts) plus a frontend JS syntax gate
**Terminal (PR #112)**
- tmux pane-buffer primitives and session/render reliability fixes for the terminal pipeline, with re-review findings addressed
**Mobile / touch (PR #111)**
- Terminal and layout fixes for touch devices: desktop focus handling, WS resize-claim wiring, CJK setting, ESC passthrough
- New: Esc button in the simple (default) keyboard accessory bar, next to paste — sends a real ESC to the session
**Defaults & UI**
- Monitor panel is now disabled by default on fresh installs (desktop previously slid it open at startup; mobile was already off). Opt in via App Settings -> Show Monitor
- Fixed the session-tab task badge silently failing to open the Monitor panel when it was hidden by the setting (long-broken on mobile)
- Local echo defaults audited and confirmed per-device: off on desktop, on for touch devices, never server-synced
## 0.9.7
### Patch Changes
+56 -32
View File
@@ -34,7 +34,7 @@ The production server caches static files for 1 year, `immutable` (`maxAge: '1y'
## COM Shorthand (Deployment)
Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`.
Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`. What SemVer actually covers (the CLI + documented env vars are public; the HTTP/SSE API, on-disk state, and experimental features are internal/unstable) is defined in `docs/versioning-policy.md`. Security reporting + known limitations live in `SECURITY.md`.
When user says "COM":
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
@@ -56,17 +56,17 @@ 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**: 0.9.7 (must match `package.json`)
**Version**: 1.1.9 (must match `package.json`)
## Project Overview
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports both Claude Code and OpenCode AI CLIs via pluggable CLI resolvers.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, and Codex (OpenAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'`).
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
**Requirements**: Node.js 18+, Claude CLI, tmux
**Requirements**: Node.js 22+, Claude CLI, tmux
**Git**: Main branch is `master`. SSH session chooser: `sc` (interactive), `sc 2` (quick attach), `sc -l` (list).
@@ -85,10 +85,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Rebuild gesture overlay | `npm run build:gesture` (esbuild `packages/gesture-control/src/codeman/entry.ts` → `src/web/public/gesture/gesture-codeman.js`; commit the result) |
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
| CI-equivalent test sweep | `npm run test:ci` (full suite minus browser/perf — see Testing) |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s) on push to master/main and on PRs (Node 22). The unit test suite is excluded (it spawns tmux).
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 3 Playwright tests). Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
@@ -96,15 +98,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-<ts>.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -115,28 +119,29 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Domain | Key files | Notes |
|--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | |
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns |
| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.4K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
| **Types** | `src/types/index.ts` (barrel) → 16 domain files (incl. `workflow-run.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`. `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
**Config**: `src/config/` — 10 files. Import from specific files, not barrel.
**Config**: `src/config/` — 14 files, no barrel (`index.ts`) exists; import from the specific file.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
### Data Flow
@@ -151,30 +156,44 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is *ours*, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated under a tunnel) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit *message*; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
**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.
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is only started when the setting is on (`server.ts` gates on `showUltracodeAgents`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on the same `showUltracodeAgents` setting), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), 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; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature *available* on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman *consumer* that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture *feel* in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applyTheme()`/`applyTerminalSkin()` apply it live on save and keep the standalone `codeman:skin` key + the settings object in sync. `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
@@ -192,9 +211,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist |
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
### SSE Event Registry
@@ -203,11 +222,13 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~134 handlers across 15 route files in `src/web/routes/`: system (40, incl. self-update `check`/`status`/`POST /api/system/update` + `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
## Adding Features
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`, use `createErrorResponse()`. Validate with Zod schemas in `schemas.ts`.
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
@@ -220,25 +241,28 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
## Testing
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
**Never run the bare full suite** (`npm test` with no file argument): the default config includes the browser-driven suites (`test/mobile/**` and 3 other Playwright tests), which need a live server + chromium + environment-specific PNG baselines and will fail/hang locally. Run individual files, or `test:ci` for a broad sweep:
```bash
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
npm test -- -t "pattern" # By name (SAFE)
# npm test # DANGEROUS — runs full suite, DON'T DO THIS
npm run test:ci # Everything except browser/perf suites — what CI runs
# npm test # DON'T — includes browser/visual suites
```
Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pass `--config config/vitest.config.ts`.
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.ci.config.ts` = same minus the browser/perf excludes — keep the two configs in sync when changing shared options.
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). `test/setup.ts` additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` so auth state from the running instance can't leak into tests.
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
## Debugging
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Claudeman Contributors
Copyright (c) 2024-2026 Codeman Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+24 -14
View File
@@ -2,24 +2,24 @@
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
</p>
<h2 align="center">The missing control plane for AI coding agents</h2>
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Agent Visualization &bull; Zero-Lag Input &bull; Autonomous Orchestrator &bull; Respawn Controller &bull; Mobile-First UI &bull; Hardened Security</em>
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
</p>
@@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
```bash
codeman web
@@ -103,7 +103,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
```
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai)). After installing, `http://localhost:3000` is accessible from your Windows browser.
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
</details>
---
@@ -214,6 +214,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
@@ -293,7 +294,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
## More Features
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Dual-CLI** — run **Claude Code** or **OpenCode** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
@@ -427,7 +428,7 @@ When someone authenticates via QR, the desktop shows a notification toast with t
## Security
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control *who* that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md).
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control *who* that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
### Network & access
@@ -447,7 +448,7 @@ These run for **every** request — before auth, even on the default no-password
### Input, files & headers
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` env-prefix allowlist gates which settings each CLI can receive
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
@@ -482,7 +483,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|----------|--------|
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
@@ -579,7 +581,7 @@ flowchart TB
end
subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"]
end
end
@@ -651,6 +653,14 @@ npm install xterm-zerolag-input
---
## Versioning
Codeman follows [SemVer](https://semver.org/). What the version number actually
commits to — and what counts as internal (the HTTP/SSE API, on-disk state,
experimental features) — is spelled out in
[`docs/versioning-policy.md`](docs/versioning-policy.md). If you script against
the HTTP API, pin to an exact version.
## License
MIT — see [LICENSE](LICENSE)
+8 -7
View File
@@ -2,10 +2,10 @@
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
</p>
<h2 align="center">为 AI 编程智能体而生的「控制平面」</h2>
<h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center">
<em>智能体可视化 &bull; 零延迟输入 &bull; 自主编排器 &bull; 重生控制器 &bull; 移动优先 UI &bull; 安全加固</em>
<em>Claude Code &bull; OpenCode &bull; Codex —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
@@ -36,7 +36,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 或 [OpenCode](https://opencode.ai)(两个都装也可以)。安装完成后:
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后:
```bash
codeman web
@@ -105,7 +105,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
```
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code) 或 [OpenCode](https://opencode.ai))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
</details>
---
@@ -216,6 +216,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
@@ -295,7 +296,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
## 更多特性
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
- **双 CLI** —— 每个会话可选 **Claude Code** 或 **OpenCode**;环境变量前缀自动隔离(`CLAUDE_CODE_*` 与 `OPENCODE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
- **图像输入** —— 直接把图片粘贴或拖放进会话
@@ -449,7 +450,7 @@ Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设
### 输入、文件与响应头
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
@@ -581,7 +582,7 @@ flowchart TB
end
subgraph External["外部"]
CLI["AI CLI<br/><small>Claude Code / OpenCode</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
BG["后台智能体<br/><small>(Task 工具)</small>"]
end
end
+78
View File
@@ -0,0 +1,78 @@
# Security Policy
Codeman launches AI coding sessions with `--dangerously-skip-permissions`, so the
web UI is **by design a remote-code-execution surface for whoever can reach it**.
The entire security model exists to control *who* that is. Please read this before
exposing an instance beyond `localhost`. The full model lives in
[`docs/security-architecture.md`](docs/security-architecture.md).
## Supported versions
Security fixes land on the latest published `codeman@X.Y.Z` release and `master`.
Older versions are not patched — upgrade to the latest release (App Settings →
Updates for git-clone installs, or `npm i -g aicodeman@latest`).
| Version | Supported |
| ------- | --------- |
| latest `0.9.x` / `master` | ✅ |
| anything older | ❌ (upgrade) |
## Reporting a vulnerability
**Please do not open a public issue for security problems.**
Report privately via **GitHub's private vulnerability reporting**:
the repository's **Security** tab → **Report a vulnerability**
(<https://github.com/Ark0N/Codeman/security/advisories/new>). This opens a private
advisory thread with the maintainer.
> Maintainer note: enable *Settings → Code security and analysis → Private
> vulnerability reporting* so this channel is live.
When reporting, please include: affected version/commit, the deployment shape
(loopback-only, `CODEMAN_PASSWORD` set, tunnel/`tailscale serve`, custom
reverse proxy), reproduction steps, and impact. We aim to acknowledge within a
few days. Coordinated disclosure is appreciated — we'll agree a disclosure
timeline with you once impact is confirmed.
### In scope
- Authentication / session-cookie bypass when `CODEMAN_PASSWORD` is set
- DNS-rebinding, CSRF/CSWSH, or Origin/Host-guard bypass reaching state-changing routes
- Remote code execution reachable **without** local OS access (e.g. via a browser, a tunnel, or a foreign origin)
- Path traversal / arbitrary file read or write through the HTTP API
- Supply-chain integrity of the in-app self-updater
### Out of scope (by design — see Known limitations)
- Anything requiring an already-trusted **same-machine, same-uid** process. Codeman trusts the local OS user it runs as; a peer process of that user is already inside the boundary.
- Running an authless instance bound to a non-loopback host after dismissing the startup warning (you explicitly acknowledged it).
- The default loopback + no-password posture itself (it is reachable only from the same machine).
## Trust model (summary)
- **Loopback by default.** Binds `127.0.0.1`; the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` *starts but prints a loud warning* with concrete fixes.
- **Always-on Host + Origin guards.** Block DNS-rebinding and cross-site state-changing requests even on the no-auth loopback install (a missing Origin is allowed so CLI/hooks work).
- **Optional auth.** HTTP Basic via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`; success issues an opaque server-side 256-bit cookie. Per-IP rate limiting on failures.
- **Hardened file serving, tmux launch, transport headers, and multi-instance isolation** — see the full architecture doc.
## Known limitations and accepted risk
A 1.0 release is an implicit statement that the documented model *is* the model, so
these residuals are stated explicitly. Most sit **inside the same-uid OS trust
boundary** or behind the always-on Origin guard; they matter mainly for
shared-host, multi-user, or tunneled deployments.
- **Self-update trusts an unsigned release tag.** The in-app updater does `git checkout <tag> && npm install` (lifecycle scripts run) of a tag matched only by name shape, from whatever `origin` points to — no signature/commit verification. Treat the updater as trusting your `origin` remote and your release pipeline. (Hardening tracked for 1.0.)
- **CSP ships `'unsafe-inline'`.** Inline handlers mean the Content-Security-Policy is defense-in-depth only; all AI-/file-derived sinks are escaped, but a future missed escape would be executable.
- **`workingDir` is unconstrained.** A session may be created with any absolute working directory (e.g. `/`), which becomes the file-route boundary for that session. Scope it to trusted paths on shared hosts.
- **Hook-event auth exemption is loopback-IP-based.** `POST /api/hook-event` is exempt from auth for loopback callers; because tunnels (cloudflared / `tailscale serve`) terminate at `127.0.0.1`, a loopback-terminating tunnel inherits the exemption. Set `CODEMAN_PASSWORD` and prefer a tunnel that preserves the client identity if this matters.
- **Session cookie is not bound to client IP/UA on reuse, and refreshes without an absolute cap.** A stolen cookie replays until its idle TTL elapses.
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
Recent hardening (this release): web-push subscription endpoints are restricted
to https public hosts (SSRF guard — rejects internal/metadata IPs, validated at
subscribe and send time), and tmux session names discovered on the shared socket
are validated against the safe-name pattern before reaching any shell call site.
For the detailed rationale, defenses, and recommended secure setups, see
[`docs/security-architecture.md`](docs/security-architecture.md).
+33
View File
@@ -0,0 +1,33 @@
import { resolve } from 'node:path';
import { defineConfig, configDefaults } from 'vitest/config';
const root = resolve(import.meta.dirname, '..');
/**
* CI test config — same as vitest.config.ts but EXCLUDES the browser-driven
* mobile suite (test/mobile/**). Those are Playwright visual-regression tests
* that need a live server + chromium + environment-specific PNG baselines, so
* they are run/maintained separately and are not part of the CI gate.
*
* Keep the rest in sync with config/vitest.config.ts.
*/
export default defineConfig({
test: {
root,
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
exclude: [
...configDefaults.exclude,
'test/mobile/**', // browser/visual (Playwright + chromium)
'test/perf-*.test.ts', // timing-sensitive perf benchmarks (flaky in CI)
'test/inline-rename.test.ts', // browser (Playwright)
'test/opencode-resize.test.ts', // browser (Playwright)
'test/webgl-fallback.test.ts', // browser (Playwright)
],
setupFiles: ['./test/setup.ts'],
fileParallelism: false,
testTimeout: 30000,
teardownTimeout: 60000,
},
});
+90
View File
@@ -0,0 +1,90 @@
# HTTP API Reference
Codeman's HTTP API is a **stable contract** as of 1.0 — see
[`versioning-policy.md`](versioning-policy.md) for the SemVer guarantee. This page
defines the response envelope, status codes, error codes, versioning, and the SSE
event channel.
## Versioning
- The stable, public surface is served under **`/api/v1/...`**. Pin external
clients to this prefix.
- The unversioned **`/api/...`** paths are a permanent alias of the current
version (what the bundled web UI uses). They are kept working, but new external
integrations should use `/api/v1`.
- Breaking changes to the contract ship under a new prefix (`/api/v2`); `/api/v1`
keeps its semantics. Additive changes (new endpoints, new optional fields, new
error codes) are non-breaking and may appear in a minor release.
- The implementation rewrites `/api/v1/*` → `/api/*` at the server level
(`rewriteApiV1Url` in `src/web/server.ts`).
## Response envelope
Every JSON response uses one uniform envelope, applied centrally by a
`preSerialization` hook (`src/web/server.ts`) — handlers return bare data and the
hook wraps it:
**Success** — HTTP `2xx`:
```json
{ "success": true, "data": <payload> }
```
`data` is the endpoint's payload (object, array, or value). Endpoints with no
payload return `{ "success": true, "data": {} }`.
**Error** — HTTP `4xx`/`5xx`:
```json
{ "success": false, "error": "human-readable message", "errorCode": "NOT_FOUND" }
```
`ApiResponse<T>` in `src/types/api.ts` is the canonical type.
> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`,
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
## Error codes → HTTP status
The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in
`src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on
the HTTP status.
| `errorCode` | HTTP | Meaning |
|-------------|------|---------|
| `INVALID_INPUT` | 400 | Malformed request / failed validation |
| `UNAUTHORIZED` | 401 | Authentication required or failed |
| `NOT_FOUND` | 404 | Resource does not exist |
| `SESSION_BUSY` | 409 | Session is busy |
| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) |
| `ALREADY_EXISTS` | 409 | Resource already exists |
| `OPERATION_FAILED` | 422 | Well-formed but could not be completed |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
Adding a new error code is non-breaking; removing or renaming one is a major change.
## Authentication
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
`codeman_session` cookie. When enabled, unauthenticated requests get
`401 UNAUTHORIZED`; rate-limited requests get `429 RATE_LIMITED`. See
[`security-architecture.md`](security-architecture.md).
## SSE event channel
`GET /api/events` is a Server-Sent Events stream (`text/event-stream`); each
message is `event: <name>` + `data: <json>`. The event-name registry
(`src/web/sse-events.ts`, mirrored in `src/web/public/constants.js`) is part of
the stable contract — event names are not renamed without a major bump. An
optional `?sessions=<id,...>` filter suppresses only the high-volume terminal
stream; lifecycle/metadata events are delivered to all clients regardless.
## Consuming from JavaScript
The bundled frontend reads responses through `_apiJson()`
(`src/web/public/api-client.js`), which unwraps `{success:true,data}` → `data` and
returns `null` on a non-2xx / `{success:false}` response. External clients should
do the same: check the HTTP status (or `body.success`), then read `body.data`.
+67 -7
View File
@@ -124,7 +124,11 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
`onRequest` hook) runs in this order:
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
**managed tunnel is running**, the hook‑event exemption additionally requires
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
so misfiring hooks can never lock out the login path.
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
allow.
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
@@ -165,17 +169,29 @@ protection is unchanged.
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
requests as local:
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
- `POST /api/hook-event` — auth‑exempt for loopback **only while no managed tunnel
is running**. When Codeman's own tunnel is up, the exemption requires the
per‑instance shared secret (`X-Codeman-Hook-Secret`, 256‑bit hex in
`~/.codeman/hook-secret`, mode 0600, COD‑54). Local hook commands read the
secret file at execution time (`$CODEMAN_HOOK_SECRET_FILE`, exported into every
managed session), so they keep working — tunneled internet traffic can't know
it. Even without the secret the impact is bounded: the route is
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
drive respawn signals, SSE broadcasts, push notifications, and transcript
watching — **not** arbitrary terminal input or file reads. It is a
session‑disruption / notification‑spoofing surface, not RCE.
watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
the **managed** tunnel — an externally run loopback proxy (your own
`cloudflared`, `tailscale serve`) is invisible to it, so the plain loopback
exemption still applies there (prefer `tailscale serve`, which authenticates at
the tailnet layer). Hook configs regenerated since COD‑54 always present the
header, so a future release can require the secret unconditionally.
- QR `/q/` — still protected by its own short‑code brute‑force limiter
(10 failures / 60s against a 62⁶ space).
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
up (it does not gate the hook‑event exemption, but it gates everything else and
is the documented practice). Prefer `tailscale serve` (below), which authenticates
up — it gates everything except the (secret‑gated) hook exemption and is the
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all.
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
@@ -304,7 +320,51 @@ injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected
`/api/download` additionally refuses a blocklist of sensitive paths
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
the control.
the control. The blocklist patterns are shared (`src/web/sensitive-path.ts`) with
the attachment guard below.
### External attachments (registry) & the magic‑link trust boundary
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
for a host file so browser requests carry the id, never an absolute path. Serving
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
routes, attachments are intentionally **cross‑workspace** — so the effective gate
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
realpath containment.
Two registration paths, with **different trust**:
- **Explicit `POST /api/sessions/:id/attachments`** (and `codeman attach`, which
POSTs directly inside a managed session) — a deliberate, Origin‑guarded HTTP
request. Allowed cross‑workspace (subject to the guard). This is the supported
path for codeman‑publish and the `~/.codeman` review‑card loop.
- **Terminal `codeman://attach?path=…` magic links** — scanned passively from
session output. Terminal output is **attacker‑influenceable** (a prompt‑injected
session can print an arbitrary path), and registration here is server‑side with
no Origin gate and broadcasts the `rawUrl` over SSE to all clients. This path is
therefore **force‑confined to the session workspace** (`forceWorkspaceConfinement`
in `registerExternalAttachment`, wired in `WebServer.registerAttachment`),
regardless of the global confine setting — a passive magic link cannot expose a
file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above.
### SSE log‑tail route — intentional extra read roots
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
into the UI) does **not** use `validateSessionFilePath`; it has its own validator
with a deliberately **wider** allowlist: the session `workingDir` **plus two
read‑only log roots — `/var/log` and `~/logs`** — so operators can tail
system/app logs. `/tmp` is intentionally excluded (world‑writable). Like the
other routes it `realpath`s the target and re‑checks right before spawning `tail`
(TOCTOU guard), and it is read‑only. This is the one place the per‑session
boundary is intentionally relaxed; on a password‑protected remote deployment an
authenticated user can therefore read `/var/log` and `~/logs` outside their
session dir. (Security review M5: this divergence is by design and is now
documented here rather than silently diverging from the per‑session claim above.)
### Known limitation — `workingDir` scope
+271
View File
@@ -0,0 +1,271 @@
# Ultracode / Workflow Agent Visualization — Design & Implementation Plan
> **Status: IMPLEMENTED (2026-06-15, rev. 3) — Phases 1–3 shipped & verified; Phase 4 (live-transcript link) deferred.** A dedicated, opt-in **master-detail tab** (`showUltracodeAgents`, default OFF) shows ultracode/Workflow runs as Claude Code's "working agents" TUI: LEFT = runs + phases (selectable tasks), RIGHT = each run's agents with model, live state, **tokens burned**, and **tool calls**.
>
> ### What rev. 3 changed vs. rev. 2 (decided during implementation against on-disk truth)
> 1. **UI is a master-detail TAB, not grouped floating subagent windows.** The user asked for the CC "working agents" view (left task picker, right agent stats). Built as a new docked panel `#ultracodeAgentsPanel` (clones `.subagents-panel` master-detail CSS) + `src/web/public/ultracode-panel.js` — NOT via `openSubagentWindow`/grouped windows.
> 2. **STANDALONE — zero edits to `subagent-watcher.ts`.** w16-claudeman's commit `f6a30d7` already discovers the per-agent workflow *transcripts* (`watchWorkflowDirs`). The data the view needs (run/phase/per-agent tokens+toolCalls) lives in the *run-state* JSON, read by a brand-new `src/workflow-run-watcher.ts` (globs the disjoint `…/workflows/wf_*.json` tree). No shared files with w16.
> 3. **No per-agent transcript streaming needed for v1.** The run-state JSON already carries `tokens`/`toolCalls`/`state`/`label`/`phase` per agent, so the whole view reads from `wf_<runId>.json` alone. (Phase 4 will optionally link a card to its already-tracked transcript via `agentId` — no watcher edits.)
> 4. **Agent states are `start | progress | done`** (verified on disk) — NOT running/queued. `start`=queued (no agentId/tokens/toolCalls yet), `done` has `durationMs`/`resultPreview`.
> 5. **The run JSON's `script` (15–660KB embedded JS), `scriptPath`, `result`, `logs` are STRIPPED in the watcher** before caching/broadcast (a 28-agent run drops 174KB → ~25KB; `promptPreview`/`resultPreview` truncated).
> 6. **SSE/snapshot ship lightweight run SUMMARIES (no `agents[]`); the RIGHT pane fetches the full run** via `GET /api/workflows/:runId` on selection. (A 25-run snapshot is ~20KB vs ~900KB if it carried every agent.) The LEFT list shows ALL cached runs (LRU-bounded), not a recency window — a run browser must show past runs.
>
> _Original rev. 2 proposal (grouped floating windows, extending subagent-watcher) preserved below for context; superseded by the above._
### What changed in rev. 2 (vs. the first draft)
1. **No backend cross-watcher coupling.** The per-agent label/phase/agentType/state **join moves to the frontend at render time** — the run object already carries every agent's entry keyed by `agentId`. This deletes `subagent-watcher`'s backward dependency on `workflow-run-watcher` (`getAgentLabel()` + its TTL cache), removes the registration-vs-run-state **race** (labels always track the latest `workflow:run_updated`), and drops the per-agent `meta.json` read from the hot path.
2. **`SubagentInfo` grows by 2 fields, not 4** (`isWorkflowAgent`, `workflowRunId`) — both derivable from the file path alone at registration, zero extra I/O. `agentType`/`label`/`phase`/`state` come from the run object on the frontend.
3. **The `isInternalAgent` bypass covers BOTH drop sites** — `registerAgentFile` *and* the late re-resolution in `processEntry`. The first draft named only one.
4. **De-duplicated.** Each trap (`journal.jsonl`, the `projects/*/*/workflows` depth, the gate-mismatch lesson, reuse-not-rebuild) is stated once in its owning section.
### Code-reuse verified against the tree (2026-06-14)
Confirmed present and shaped as assumed: `subagent-watcher.ts` — `watchSubagentDir`/`registerAgentFile`/`tailFile`/`processEntry`, `getRecentSubagents`, `isInternalAgent` (drops on `MIN_DESCRIPTION_LENGTH=5`), `STARTUP_MAX_FILE_AGE_MS=4h`, `MAX_TRACKED_AGENTS`, `knownSubagentDirs`/`dirWatchers`. `team-watcher.ts` — `configMtimes` mtime-skip + chokidar + `setInterval` poll. `server.ts` — `setupSubagentWatcherListeners`, `getLightState()` (`subagents: getRecentSubagents(15)`, `LIGHT_STATE_CACHE_TTL_MS=1000`), `isSubagentTrackingEnabled()` (`settings.subagentTrackingEnabled ?? true`). Frontend — `_SSE_HANDLER_MAP`, `this.subagents` Map, `handleInit`/`cleanupAllFloatingWindows`, `renderSubagentPanel`/`_renderSubagentPanelImmediate`, `getTeammateBadgeHtml`, `openSubagentWindow` + `.subagent-window-parent` sub-header.
## 1. The enabling fact: on-disk artifacts
The Workflow tool (what `ultracode` drives) persists each workflow agent as a transcript under the **same `subagents/` directory Codeman already watches**, one level deeper. Empirically verified against a real run (`wf_a8e09f2c-550`); **re-confirm the shape against a fresh run at implementation time** (§8 mandates a live e2e pass anyway):
```
~/.claude/projects/<projHash>/<sessionUuid>/
├─ subagents/
│ ├─ agent-XX.jsonl ← regular Task subagent (tracked today)
│ └─ workflows/wf_<runId>/
│ ├─ agent-YY.jsonl ← WORKFLOW agent — IDENTICAL line format
│ ├─ agent-YY.meta.json ← {"agentType":"workflow-subagent"} (optional enrichment)
│ └─ journal.jsonl ← run journal {type:"started",...} — MUST be skipped
└─ workflows/wf_<runId>.json ← run state: runId, workflowName, summary, status,
phases[], workflowProgress[], totals (DIFFERENT tree)
```
The per-agent `.jsonl` line shape is identical to a regular subagent transcript:
```jsonc
{ "parentUuid": null, "isSidechain": true, "agentId": "ac6a1d27012a64e38",
"type": "user" | "assistant", "message": { "role": "...", "content": "..." }, ... }
```
Because the line shape is identical, the entire existing parse→event→render pipeline works unchanged once discovery reaches those files. The only new data is the **run-level metadata** in `workflows/wf_<runId>.json` (name, summary, phases, and `workflowProgress[]` — the per-agent labels/state/tools), which supplies the group header and per-agent labels.
**Can show:** per-agent live transcript (tool calls, messages, results); per-agent status (active/idle/completed via the existing mtime/PID/pgrep liveness); per-agent model + running token totals (from each agent's JSONL `message.usage`, exactly as today); the run's `workflowName`/`summary`/`phases[]`; per-agent `label`/`phaseTitle`/`state`/`lastToolName` (from `workflowProgress[]`); grouping under `wf_<runId>`.
**Cannot show:** anything absent from the artifacts — a live phase cursor beyond `workflowProgress[].state`; an authoritative **budget/cost ceiling** (only consumed totals exist — `usage` + run-state `totalTokens`, no remaining-budget field); runs older than `STARTUP_MAX_FILE_AGE_MS` (4h) after a server restart (live monitoring only).
## 2. Architecture
**Decision: EXTEND `subagent-watcher.ts` for per-agent discovery/streaming; ADD a thin `workflow-run-watcher.ts` (modeled on `team-watcher.ts`) for the group-header metadata ONLY. The agent→run-metadata join happens on the FRONTEND, so the two watchers stay decoupled.**
- The per-agent JSONL is identical in shape, so re-running it through `registerAgentFile()` → `tailFile()` → `processEntry()` and the existing `subagent:*` events is free and reconnect-safe (those agents land in `agentInfo`, replayed by `getRecentSubagents(15)`). A parallel per-agent watcher would duplicate the liveness/token/tool-call/SSE machinery for zero benefit.
- Run metadata lives in a *different* file under a *different* tree (`workflows/wf_<runId>.json`, sibling to `subagents/`). A small `WorkflowRunWatcher` watching `projects/*/*/workflows/wf_*.json` (mtime-skip, like `team-watcher`'s `configMtimes`) is the clean home; folding it into `subagent-watcher` would entangle two unrelated watch roots and put a JSON re-read in the hot per-line path.
- **The two watchers never call each other.** The frontend receives both streams and joins agent→label by `agentId` at render time (the run object carries every agent's entry). This removes the timing coupling entirely.
```
~/.claude/projects/<projHash>/<sessionUuid>/
├─ subagents/
│ ├─ agent-XX.jsonl ──────────────► SubagentWatcher (EXTENDED: also descends
│ └─ workflows/wf_<runId>/ workflows/wf_<runId>/, tags isWorkflowAgent+runId)
│ ├─ agent-YY.jsonl ─┐ reuse registerAgentFile/tailFile/processEntry
│ └─ journal.jsonl (SKIP) emits subagent:* (now w/ 2 workflow fields)
└─ workflows/wf_<runId>.json ──────► WorkflowRunWatcher (NEW, team-watcher-shaped)
{workflowName,phases,workflowProgress[]} emits workflow:run_discovered|updated|removed
server.ts
setupSubagentWatcherListeners() ──► broadcast(subagent:*) ─┐
setupWorkflowRunWatcherListeners() ──► broadcast(workflow:run_*) │ SSE
getLightState(): subagents + workflowRuns ───────────────────────┘
│
▼ app.js dispatch table
panels-ui: partition this.subagents by workflowRunId; header + per-agent
labels JOINED from this.workflowRuns.get(runId).agents (by agentId)
```
## 3. Backend changes (ordered, file-by-file)
### 3a. `src/subagent-watcher.ts` — nested discovery + 2 tag fields
**(1) Extend `SubagentInfo` with exactly two optional fields** (optional → regular subagents and the wire shape are unaffected):
```ts
isWorkflowAgent?: boolean; // true when discovered under subagents/workflows/<wf_runId>/
workflowRunId?: string; // e.g. "wf_23dbeab2-152" (parent dir name)
```
Both are derived from the **file path alone** at registration — no extra reads. They ride existing `subagent:discovered|updated|completed` payloads (no new per-agent event). Do **not** add `agentType`/`label`/`phase`/`workflowName` here — those come from the run object on the frontend (§4c).
**(2) Constant.** `const WORKFLOWS_SUBDIR = 'workflows';` near the existing dir constants.
**(3) `watchSubagentDir()` — descend into `workflows/<wf_runId>/`.** After the existing direct-child registration loop:
```ts
// Workflow agents live one level deeper: subagents/workflows/<wf_runId>/agent-*.jsonl
const wfRoot = join(dir, WORKFLOWS_SUBDIR);
try {
for (const runId of await readdir(wfRoot)) {
if (!runId.startsWith('wf_')) continue;
await this.watchWorkflowRunDir(join(wfRoot, runId), projectHash, sessionId, runId);
}
} catch { /* no workflows subdir — normal for most sessions */ }
```
The existing `fs.watch(dir, …)` on `subagents/` is **non-recursive on Linux** and won't fire for writes inside `workflows/<runId>/`, so each run dir needs its own watcher.
**(4) New private `watchWorkflowRunDir(runDir, projectHash, sessionId, runId)`** — clone `watchSubagentDir`'s structure, but:
- Register only files matching `^agent-.*\.jsonl$`, **explicitly skipping `journal.jsonl`** (it ends in `.jsonl` but is `{type:'started',…}`, not a transcript — registering it would create a phantom agent).
- Call `registerAgentFile(filePath, projectHash, sessionId, isInitialScan, runId)` so the agent is tagged.
- Install one `watch(runDir, …)` per run dir; on `error` and `stop()`, reuse the existing teardown (close + delete from `dirWatchers`/`knownSubagentDirs`/`dirWatcherErrorHandlers`).
- Guard re-registration **per run dir** in `knownSubagentDirs`, **not** `wfRoot` — the 5s full scan must still re-`readdir(wfRoot)` to pick up *new* `wf_<runId>` dirs created mid-session.
**(5) `registerAgentFile()` — accept + apply `runId`.** Add a trailing optional `runId?: string`. When set, the whole change is:
```ts
if (runId) { info.isWorkflowAgent = true; info.workflowRunId = runId; }
```
No `meta.json` read, no run-state lookup, no description override. `agentId`s are globally unique `a<16hex>` (verified: 0 collisions across a 370-agent corpus), so keep the flat `agentInfo` map keyed by `agentId` — do **not** switch to a composite key. Add a one-line dev-assert log if `agentInfo.has(agentId)` with a *different* `workflowRunId`, so a future collision is observable.
**(6) `isInternalAgent` bypass — BOTH drop sites.** Workflow agents have no Task-tool spawn record, so `_resolveDescription` yields only the first-user-message fallback (often a long phase prompt) or empty → `isInternalAgent` (`length < MIN_DESCRIPTION_LENGTH`) would wrongly drop them. They are real by construction (the `subagents/workflows/wf_*/` path is the discriminator). Gate the drop on `!info.isWorkflowAgent` at **both** places:
- `registerAgentFile` initial check (`isInternalAgent(description)`),
- `processEntry`'s late re-resolution (the second `isInternalAgent` call).
**(7) `stop()` teardown.** Per-run watchers live in `dirWatchers`, so the existing close-all loop covers them — verify no separate map was introduced (24h runs spawn many `wf_<runId>` dirs → FSWatcher leak risk).
### 3b. NEW `src/workflow-run-watcher.ts` (singleton, EventEmitter — model on `team-watcher.ts`)
- **Watch root:** `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` — **two** levels under `projects` (verified: `projects/*/workflows` is empty; must be `projects/*/*/workflows/`). chokidar `depth:3` + a poll fallback, mirroring `team-watcher`'s dual discovery + interval.
- **mtime-skip:** `runMtimes: Map<absPath, number>` (mirror `team-watcher.configMtimes`).
- **Parse:** read `wf_<runId>.json`, take the **top-level structured keys** (`runId`, `workflowName`, `summary`, `status`, `phases:[{title,detail}]`, `agentCount`, `defaultModel`, `durationMs`, `totalTokens`, `totalToolCalls`, `workflowProgress[]`). **Do NOT parse the embedded `script` string** — name/phases/summary are already top-level; the script's `export const meta` is redundant and costly. Derive `sessionUuid` from the dir name, `projectHash` from the dir above; expose `getProjectHash(workingDir)` for Codeman-session correlation.
- **`workflowProgress[] → agents[]`:** filter `type === 'workflow_agent'`, map each to a `WorkflowAgentEntry` (§3c) keyed by `agentId`. **This array is the join source the frontend uses** — no backend `getAgentLabel()` API, no TTL cache, no import from `subagent-watcher`.
- **Emit** `workflow:run_discovered|updated|removed` carrying `WorkflowRunInfo`; removal by set-diff (mirror `team-watcher`).
- **Lifecycle:** `start()`/`stop()` with `CleanupManager` teardown of chokidar + interval + caches; `LRUMap`-bounded run cache (24h memory rule).
### 3c. `src/types/` — workflow run types
```ts
export interface WorkflowAgentEntry { // one workflowProgress[type==='workflow_agent']
agentId: string; label: string; phaseIndex?: number; phaseTitle?: string;
agentType?: string; model?: string; state?: string; // 'done'|'running'|'queued'|...
lastToolName?: string; lastToolSummary?: string; tokens?: number; toolCalls?: number;
}
export interface WorkflowRunInfo {
runId: string; sessionUuid: string; projectHash: string;
workflowName?: string; summary?: string; status?: string; // 'running'|'completed'|...
phases: Array<{ title: string; detail?: string }>;
agentCount?: number; defaultModel?: string;
agents: WorkflowAgentEntry[]; // workflowProgress filtered to workflow_agent, keyed by agentId
startedAt?: number; durationMs?: number; totalTokens?: number; totalToolCalls?: number;
}
```
The two `SubagentInfo` workflow fields stay inline in `subagent-watcher.ts` (matching the existing convention).
### 3d. `src/web/sse-events.ts` — register run events
Add `workflow:run_discovered`, `workflow:run_updated`, `workflow:run_removed` after the `subagent:*` block and to the `SseEvent` union. **No new per-agent event** — workflow agents reuse `subagent:*`.
### 3e. `src/web/server.ts` — bridge, snapshot, gating
- **`setupWorkflowRunWatcherListeners()`** (beside `setupSubagentWatcherListeners`): map the three run events → `this.broadcast(...)`. Add `cleanupWorkflowRunWatcherListeners()` (store handler refs).
- **Start/stop:** call `workflowRunWatcher.start()`/`.stop()` beside `subagentWatcher`, **gated on the same enable condition** (§3f).
- **`getLightState()`:** add `workflowRuns: workflowRunWatcher.getRecentRuns(15)` beside `subagents: subagentWatcher.getRecentSubagents(15)` so headers replay on reconnect (agents already replay via `subagents`). Keep the `LIGHT_STATE_CACHE_TTL_MS` memoization.
- **Gating read:** add `isWorkflowAgentTrackingEnabled()` mirroring `isSubagentTrackingEnabled()` (boot-time `dataPath('settings.json')` read). Gate `workflowRunWatcher.start()` **and** the subagent-watcher `workflows/` descent (§3a-3) on `showUltracodeAgents` so non-opted-in users never register historical workflow agents.
### 3f. `src/web/schemas.ts` — settings key
Add `showUltracodeAgents: z.boolean().optional()` to the `.strict()` settings update schema near `showPlanUsageLimits` (required — `.strict()` 400s the whole PUT on an unknown key).
### 3g. `src/web/routes/system-routes.ts` — poll API
- `GET /api/subagents` and `GET /api/sessions/:id/subagents` include workflow agents once registered — **no change** (they carry `isWorkflowAgent`/`workflowRunId`; a consumer joins to `/api/workflows/:runId` for labels).
- Add `GET /api/workflows` → `workflowRunWatcher.getRecentRuns()` and `GET /api/workflows/:runId` (uniform `ApiResponse` contract; headers are also in `getLightState`).
- `GET /api/subagents/:agentId/transcript` works for workflow agents (they're in `agentInfo`) — no new route.
## 4. Frontend changes (file-by-file)
### 4a. `src/web/public/constants.js`
- Add the three SSE strings to `SSE_EVENTS`, matching §3d exactly (`WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered'`, etc.).
- Reuse `ZINDEX_SUBAGENT_BASE=1000` for the agent windows (they ARE subagent windows). The group **header/cluster** is in-flow panel DOM, not a floating window — no new z-index (1100 is plan-subagent).
### 4b. `src/web/public/app.js`
- Constructor: `this.workflowRuns = new Map(); // runId -> WorkflowRunInfo` beside `this.subagents`.
- `_SSE_HANDLER_MAP`: add three rows → `_onWorkflowRunDiscovered/Updated/Removed` (must exist before `connectSSE` builds the wrappers).
- `handleInit`: after seeding `data.subagents`, seed `this.workflowRuns` from `data.workflowRuns` (clear-then-set). **Clear `this.workflowRuns` everywhere the subagent Maps are cleared** (incl. `cleanupAllFloatingWindows`) — 24h leak guard.
### 4c. `src/web/public/panels-ui.js` — the join lives here
- `_onWorkflowRunDiscovered/Updated(data)` → `this.workflowRuns.set(data.runId, data)` + debounced re-render; `_onWorkflowRunRemoved` → delete + re-render.
- **No change to `_onSubagentDiscovered/Updated`** — they already store the whole payload, so the 2 new fields ride along.
- `renderSubagentPanel`/`_renderSubagentPanelImmediate`: when `showUltracodeAgents` is on, **partition `this.subagents` into flat (no `workflowRunId`) vs grouped-by-`workflowRunId`**. Flat agents render exactly as today. For each group: build the header from `this.workflowRuns.get(runId)` (`workflowName` + phase/status chip from `phases[]`), then render that run's agents reusing the existing per-agent row markup. **Per-agent label/phase/agentType come from the JOIN** — build `Map(agentId → entry)` from `this.workflowRuns.get(runId).agents` and look each agent up by `agent.agentId`; render the small chip via the `getTeammateBadgeHtml` pattern. (If the run object hasn't arrived yet, fall back to the agent's own `description` — the run `:updated` event will fill it in on the next render.)
- `findParentSessionForSubagent` is unchanged — workflow agent `sessionId === session.claudeSessionId`. **Do not conflate `workflowRunId` with `sessionId`.**
### 4d. `src/web/public/subagent-windows.js`
**Decision: REUSE `.subagent-window` per agent + a group sub-header — do NOT build a cluster class.** A cluster path duplicates Map/z-index/drag/cleanup/persistence for no functional gain; reuse keeps connection lines, minimize-to-tab, and `localStorage` persistence. In `openSubagentWindow`, where the optional `.subagent-window-parent` sub-header is built: when `agent.workflowRunId` is set, inject a `.subagent-workflow-header` showing `this.workflowRuns.get(runId)?.workflowName` + the joined agent's `label`/phase (look up by `agentId`), mirroring the `from <session>` sub-header. Respect the existing skip guards (teammate-terminal windows, minimized/`_lazyTerminal`).
**Do NOT auto-open windows** for workflow agents — a multi-phase run can spawn many, against the 50-window/60fps budget + `MAX_TRACKED_AGENTS=500`. They render collapsed in the grouped panel; the user expands via the existing panel buttons.
### 4e. `src/web/public/settings-ui.js` + `index.html`
- `index.html` Panels block: add a `settings-item` checkbox `id="appSettingsShowUltracodeAgents"` ("Show ULTRACODE / Workflow Agents").
- `openAppSettings`: load `settings.showUltracodeAgents` with `false` fallback (mirror `showPlanUsageLimits`).
- `saveAppSettings`: collect `showUltracodeAgents` into the fresh settings literal (uncollected keys reset to default every save).
- Live-apply on toggle: re-run `renderSubagentPanel()` (show/hide group sections) — a panel re-render, not a CSS-class strip.
- **SYNCED, not per-device:** do NOT add `showUltracodeAgents` to `displayKeys` and do NOT strip it in the per-device block. A synced value gives the server-side gate (`isWorkflowAgentTrackingEnabled`, §3e) one canonical truth to decide whether to run the watcher; a per-device value can't gate a process-wide watcher. (Contrast `showResponseViewer`, pure client display.)
- `styles.css` + `mobile.css`: add `.subagent-workflow-header` and `.subagent-group-badge` next to `.subagent-window-parent`; mirror device overrides in `mobile.css`.
## 5. Settings / opt-in wiring
- **Key:** `showUltracodeAgents` (boolean, **default OFF**). Fallback `false` in `openAppSettings`; "absent ⇒ off" in `isWorkflowAgentTrackingEnabled()`. Schema `z.boolean().optional()` in the `.strict()` update schema, kept OUT of `displayKeys` (synced).
- **Runtime gating:** `workflowRunWatcher.start()` and the subagent-watcher `workflows/` descent run only when the boot-time `settings.json` read reports `showUltracodeAgents === true` (mirroring `isSubagentTrackingEnabled`). The frontend additionally gates display. Toggling at runtime gates **display** immediately (panel re-render); the **watcher branch** picks up on next boot — matches existing `subagentTrackingEnabled` semantics. (Optional polish: restart just the workflow watcher on toggle for instant on/off.)
## 6. SSE events
**Reused (no change):** `subagent:discovered|updated|tool_call|tool_result|progress|message|completed`. Workflow agents flow through these; payloads now carry the optional `isWorkflowAgent`/`workflowRunId` fields on `SubagentInfo`. SSE payloads aren't schema-gated (typed only at `broadcast()` call sites), so the new fields propagate with zero friction.
**New (3 events, run-level metadata):**
| Event (backend const / frontend key) | Payload |
|---|---|
| `workflow:run_discovered` / `WORKFLOW_RUN_DISCOVERED` | `WorkflowRunInfo` |
| `workflow:run_updated` / `WORKFLOW_RUN_UPDATED` | `WorkflowRunInfo` |
| `workflow:run_removed` / `WORKFLOW_RUN_REMOVED` | `{ runId: string }` |
Sync requirement (CLAUDE.md): each must appear in **both** `sse-events.ts` (§3d) and `constants.js` `SSE_EVENTS` (§4a), be emitted via `broadcast()` in `setupWorkflowRunWatcherListeners()` (§3e), and have a dispatch-table row + `_on*` handler (§4b/§4c).
## 7. Edge cases & cleanup
- **`journal.jsonl` phantom-agent trap** — owned by §3a-4: run-dir registration requires the `agent-` prefix and excludes `journal.jsonl`.
- **`isInternalAgent` over-filtering** — owned by §3a-6: bypass at BOTH drop sites; titled from the frontend join (or the description fallback).
- **No workflow agents in the flat list** — `renderSubagentPanel` partitions on `agent.workflowRunId` (§4c). When the toggle is OFF, the descent never ran, so they aren't in `this.subagents` at all.
- **Completion/idle** — keep the existing per-agent mtime/PID/pgrep liveness as the per-card source of truth. Optionally render a group-level "workflow done" badge from run-state `status==='completed'`.
- **Limits** — `MAX_TRACKED_AGENTS=500` LRU-evicts workflow agents in the same flat map; no auto-open (50-window budget); the 4h `STARTUP_MAX_FILE_AGE_MS` skip means a run completed >4h ago won't reload after restart (acceptable — live monitoring).
- **Reconnect/replay** — agents via `getRecentSubagents(15)`; headers via `workflowRuns: getRecentRuns(15)` in `getLightState`. `handleInit` clears `this.workflowRuns` alongside the subagent Maps.
- **Watcher teardown** — every per-run `fs.watch` and the chokidar watcher closes in `stop()` and on `error`; `CleanupManager` for the new watcher (24h runs create many run dirs).
- **CLAUDE.md discipline** — read-only `~/.claude/...` artifacts; no new `~/.codeman/...` paths, no env-var prefixes touched. Claude-mode-only by nature (external CLIs don't write workflow transcripts).
## 8. Testing & verification
- **Unit (pure):**
- `test/workflow-run-watcher.test.ts`: feed a scrubbed fixture `wf_<runId>.json` → assert `WorkflowRunInfo` extraction (name/summary/phases, `workflowProgress`→`agents[]` keyed by `agentId`), mtime-skip, removal-by-set-diff.
- Extend `subagent-watcher` coverage: temp `subagents/workflows/wf_X/agent-Y.jsonl` + a stray `journal.jsonl` → assert `agent-Y` registered with `isWorkflowAgent`/`workflowRunId` and `journal.jsonl` NOT registered; assert a short-description workflow agent is NOT dropped at **either** `isInternalAgent` site.
- **Route/inject (`app.inject`):** `GET /api/workflows` + `:runId` return the `ApiResponse` envelope; `GET /api/subagents` includes a tagged agent.
- **Frontend (vm-sandbox, like `test/run-mode-ui.test.ts`):** dispatch `subagent:discovered` with `workflowRunId` + `workflow:run_discovered` → assert `renderSubagentPanel` produces a group section under the workflow name with the agent inside it (label sourced from the **join**, not flat); assert order-independence (agent before run, and run before agent both resolve); assert OFF hides the section.
- **REQUIRED real end-to-end** (the always-end-to-end-test rule — the plan-usage chip shipped *dead* from a gate mismatch): on dev/beta with `showUltracodeAgents` ON, **drive a real ultracode/workflow run**, then (1) `curl …/api/workflows | jq` shows the live run with `agents[]`; (2) `curl …/api/subagents | jq '.data[]|select(.isWorkflowAgent)'` shows tagged agents; (3) watch `/api/events` for `workflow:run_discovered` + `subagent:discovered` with the workflow fields; (4) Playwright (`waitUntil:'domcontentloaded'`, wait 3–4s) asserts the grouped DOM cluster renders with the workflow-name header and live status. Verify path gates against `GET /api/sessions` `workingDir`. **Test against a LIVE run** — all at-rest runs are `completed`/`done`; `running`/`queued` states only exist mid-run.
## 9. Phased rollout
| Phase | Scope | Done-check | Size |
|---|---|---|---|
| **P1 — Backend discovery + tagging (gated, no UI)** | §3a (nested descent, `journal.jsonl` skip, 2 `SubagentInfo` fields, `isInternalAgent` bypass ×2) + §3f schema key + §3e gate read. No run watcher yet. | With `showUltracodeAgents` forced on, `curl /api/subagents \| jq '.data[]\|select(.isWorkflowAgent)'` lists real workflow agents during a live run; flat subagents unchanged; `tsc --noEmit` + targeted watcher test green. | S–M |
| **P2 — Run-state metadata + SSE** | §3b (`workflow-run-watcher.ts`) + §3c types + §3d/§3e (SSE, bridge, `getLightState` replay) + §3g routes. | `curl /api/workflows \| jq` returns runs with `agents[]`/`phases`; SSE emits `workflow:run_discovered`; reconnect snapshot carries `workflowRuns`. | M |
| **P3 — Frontend grouped UI** | §4a–§4d (constants, app.js state/dispatch/init, panels-ui grouped render + **agent→label join**, subagent-windows group sub-header). Reuse `.subagent-window`; no auto-open. | Playwright: live run renders a group section under the workflow name with per-agent rows + live status + joined labels; flat subagents stay flat; expand opens a window with the workflow sub-header. | M |
| **P4 — Settings toggle + polish + docs** | §4e (checkbox, settings-ui load/save/live-apply, SYNCED), styles/mobile, phase chips, CLAUDE.md "Key Patterns" entry + this doc's status → SHIPPED. | Toggling the checkbox shows/hides the cluster live (no reload for display); OFF by default on a fresh install; CI green. | S |
Each phase is independently shippable: P1 is invisible (gated, no UI), P2 adds an API with no UI dependency, P3 lights up the UI for flag-enablers, P4 exposes the toggle and finalizes defaults/docs.
## 10. Effort & risk
**Size:** P1 = S–M, P2 = M, P3 = M, P4 = S. Total ≈ **M** (one focused engineer, ~2–4 days incl. the real end-to-end run — down from the first draft's M-L now that the backend join/coupling is gone).
**Top 3 risks:**
1. **Non-recursive watch on Linux misses live writes.** `fs.watch` is non-recursive and `{recursive:true}` is unreliable on Linux → per-`wf_<runId>` watchers (§3a-4) are correct, but the 5s full scan must re-`readdir(wfRoot)` to catch *new* run dirs mid-session, and each watcher must be torn down to avoid FSWatcher leaks in 24h runs. Mitigation: explicit per-run-dir registration + verified `dirWatchers` teardown; chokidar (with `CleanupManager`) only in the new run watcher, where `team-watcher` already proves the pattern.
2. **Discovery cost / over-registration.** A user with hundreds of historical workflow agents could flood `agentInfo` on boot. Mitigation: the 4h `STARTUP_MAX_FILE_AGE_MS` skip drops old files on the initial scan, the descent only runs when the toggle is on, and `MAX_TRACKED_AGENTS=500` LRU-evicts. Verify boot scan time doesn't regress with the corpus present.
3. **Shipping-dead-on-a-gate** (the repo's recurring failure mode — the plan-usage chip shipped dead because injection was gated on `CASES_DIR` while real sessions ran elsewhere). Same trap here if the path/mode gate is wrong (e.g. `projects/*/workflows` instead of `projects/*/*/workflows`, or correlation via the wrong session key). Mitigation: the **mandatory live ultracode end-to-end run** in §8 against a real session's `workingDir`, observing the real SSE event + real DOM cluster — not the at-rest corpus, not unit tests alone.
+169
View File
@@ -0,0 +1,169 @@
# Plan Usage Limits Display — Design & As-Built
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
>
> Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
> - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`.
>
> The `rate_limits` JSON schema below was **empirically confirmed** against Claude Code 2.1.177 on a Claude Max account; see the Verification appendix to reproduce.
## Problem
Codeman had no proactive view of how much of the Claude subscription is left. It only learned about limits **reactively**: `usage-limit-patterns.ts` regex-scrapes ANSI-stripped terminal output for footer strings like `5-hour limit reached ∙ resets 8pm`, extracting only the **reset time**, and only *after* Claude has already stalled. There was no "73% of your 5-hour limit used" anywhere.
We wanted a live, always-visible gauge so the operator can see a wall coming and pace overnight/autonomous runs — without hijacking the in-terminal statusline, which should keep showing the current session's status.
## Data source: the statusline `rate_limits` JSON
Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a configured `statusLine.command` on stdin after each render. On Pro/Max subscriptions that blob includes `rate_limits`. **This is the only channel that exposes plan-limit data** (see rejected alternatives) — so the feature *must* set a statusLine command, which is why the footer is also reconstructed by it (below).
### Confirmed schema (real captured payload)
```jsonc
"rate_limits": {
"five_hour": { "used_percentage": 15, "resets_at": 1781409000 }, // → 2026-06-14T03:50:00Z
"seven_day": { "used_percentage": 34, "resets_at": 1781827200 } // → 2026-06-19T00:00:00Z
}
```
| Field | Type | Notes |
|-------|------|-------|
| `rate_limits.five_hour.used_percentage` | `number` 0–100 | Integer-valued in practice; treat as `number`, don't assume decimals. |
| `rate_limits.five_hour.resets_at` | `number` | **Epoch SECONDS** (10 digits). `×1000` for a JS `Date`. |
| `rate_limits.seven_day.{used_percentage,resets_at}` | same | |
**Confirmed facts & gotchas:**
- **Only two windows exist: `five_hour` and `seven_day`.** There is **no separate Opus-weekly field**, even on a Max/Opus account.
- `rate_limits` is **absent on the first render**, **present after the first API response**. UI degrades to "no chip yet."
- statusLine fires **only in interactive TUI mode**, never `--print`. Fine — Codeman sessions are interactive TUIs (and so are Codeman-spawned ones in tmux).
- **Subscriber-gated.** Absent for API-key / non-subscriber auth.
### Bonus telemetry in the same payload — used for the footer
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
### Alternatives considered & rejected
| Source | Why not |
|--------|---------|
| OAuth endpoint `api.anthropic.com/api/oauth/usage` | Undocumented, aggressively rate-limited, needs the **encrypted** OAuth token. Only worth it for *dollar spend*. |
| `/usage` slash command | Interactive-only, no programmatic output. |
| On-disk `~/.claude/` files | No usage state persisted (only `daemon.status.json` = auto-updater supervisor). |
| CLI flag (`claude usage` / `--check-usage`) | Does not exist. |
| `StopFailure` hook | Carries only an `error_type` on *failure* — no live percentages. |
## As-built architecture
```
Claude TUI (any Claude session, incl. linked-case/real-repo sessions)
│ renders statusline after each assistant msg (+ /compact, mode change)
▼
statusLine.command (settings.local.json) ──reads stdin JSON──▶
curl -sk POST $CODEMAN_API_URL/api/status-telemetry {sessionId, data}
(X-Codeman-Hook-Secret: $(cat $CODEMAN_HOOK_SECRET_FILE))
│ ◀── HTTP 200 text/plain = current-SESSION status string ──┘
▼
printf '%s' "$body" → in-terminal footer: "Opus 4.8 (1M context) in:… out:… ctx:…%"
server (status-telemetry-routes.ts):
parse rate_limits → (if changed) store last-known + broadcast SSE session:statusTelemetry → header chip
parse model/tokens/ctx → return the session-status footer string
▼
app.js: _onSessionStatusTelemetry → chip (per-window colors) + localStorage save
handleInit → chip from init-snapshot planUsage (fresh-load replay)
```
### 1. The exporter — `generateStatusLineCommand()` in `hooks-config.ts`
Mirrors the hook `curlCmd()`. Reads the stdin JSON, POSTs `{sessionId, data}` to a **fixed** loopback path, and prints the response body back to stdout (print-through, so the footer stays useful). The managed-session env carries `$CODEMAN_SESSION_ID` / `$CODEMAN_API_URL` / `$CODEMAN_HOOK_SECRET_FILE` (from `tmux-manager.buildEnvExports()`).
```bash
INPUT=$(cat 2>/dev/null || echo '{}'); \
printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | \
curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" \
-H 'Content-Type: application/json' \
-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" \
--data @- 2>/dev/null || echo codeman
```
⚠️ **`curl -sk`, not `curl -s`.** Prod is loopback **HTTPS with a self-signed cert**; without `-k`, curl returns `000` and the statusline silently shows nothing. `-k` is safe (loopback only). *(The existing hook curls use `-s` without `-k` and have the same latent issue on HTTPS installs — a known, separate follow-up.)*
### 2. Endpoint — `POST /api/status-telemetry` (`status-telemetry-routes.ts`)
Fixed path (sessionId in the **body**, not the URL) so the auth exemption is an exact-match like `/api/hook-event` (`middleware/auth.ts`: loopback-only; `X-Codeman-Hook-Secret`-gated while a tunnel runs). Schema `StatusTelemetrySchema` in `schemas.ts` validates the subset; unknown keys are stripped. Pure parsing/formatting in `usage-telemetry.ts`:
- `parseStatusTelemetry(data)` → `{ fiveHour, sevenDay, … }` or `null`. On change (signature dedup; statusline fires often), store last-known (`plan-usage-latest.ts`) and `broadcast('session:statusTelemetry', { sessionId, …telemetry })`.
- `parseSessionStatus(data)` + `formatSessionStatusText()` → the **footer** string `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` (returned as `text/plain`). Available from the first render, even before `rate_limits` appears.
### 3. SSE + frontend chip
`session:statusTelemetry` registered in `sse-events.ts` + `constants.js`. `app.js`:
- `_onSessionStatusTelemetry` → `updatePlanUsageChip(data)` + save to `localStorage['codeman:planUsage']`.
- `updatePlanUsageChip` renders two `5h`/`7d` windows; **per-window color by usage** — green `<60%`, yellow `60–84%`, red `≥85%` (`pu-green/pu-yellow/pu-red`); bold labels/values; reset times in the tooltip. `resets_at*1000 → Date`.
- Chip element ships hidden (`header-plan-usage--hidden`); `applyHeaderVisibilitySettings()` reveals it client-side when the setting is on (response-viewer pattern — **no `renderIndexHtml` strip**, which kept the "title-only" render contract intact).
### 4. Chip data robustness — three layers
1. **Live:** `session:statusTelemetry` SSE on every distinct render.
2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process.
3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard).
### 5. Injection lifecycle — works for *any* user, never self-destructs
The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`).
- **On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip *immediately*, no new session needed, no dependency on a client's synced localStorage.
- **On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**. Sessions in a repo share one `settings.local.json`, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle.
- `applyStatusLineConfig()` is **`isOurs`-guarded** (matches `/api/status-telemetry`), so a user's own hand-authored statusLine is never touched, and it **updates an out-of-date ours-command** so fixes (e.g. `-k`) propagate. **No `CASES_DIR` gate** — runs for linked cases / real repos (where sessions actually run), mirroring `updateCaseModel`.
## Codeman-specific considerations
1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar.
2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there.
3. **`isOurs`-guarded.** Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter.
4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate.
5. **Claude-only.** OpenCode/Codex emit no `rate_limits` JSON; injection is gated to `mode === 'claude'`.
6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built.
## Files shipped
- `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`.
- `src/hooks-config.ts` — `generateStatusLineCommand()` (`curl -sk`), `applyStatusLineConfig()` (add/update/remove, `isOurs`-guarded).
- `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`.
- `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay.
- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` + create-payload `statusLineTelemetry`.
- `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`.
- `src/web/routes/session-routes.ts` — add-only create-time injection.
- `src/web/routes/system-routes.ts` — settings-toggle reconcile.
- `src/web/server.ts` — `getLightState().planUsage` (init snapshot).
- `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`.
- Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload).
## Bugs E2E testing caught (that unit tests didn't)
The first "shipped" build passed every test and was broken in practice. End-to-end testing on the real install (the lesson: drive a REAL session, observe the REAL output) surfaced:
1. **`CASES_DIR` injection gate** excluded the user's whole workflow — sessions run in linked cases / real repos, not under `~/codeman-cases`. → dropped the gate.
2. **`curl -s` → `000`** on the loopback self-signed HTTPS cert; statusline silently empty. → `curl -sk`.
3. **Remove-on-create-false + shared `settings.local.json`** let a single stale client yank the statusLine out from under all sessions in a repo. → add-only on create; removal only via the toggle reconcile.
4. **Chip blank after reload** (localStorage-only, lost on restart/fresh browser). → server-side last-known in the init snapshot.
## Open questions / future
- **Schema stability.** `rate_limits` is officially shipped but undocumented in exact shape; the parser is tolerant (renders whatever windows exist, ignores unknown).
- **Hook `curl -s` parity.** Hooks share the no-`-k` issue on HTTPS installs — worth fixing the hook curl too (separate change; covered by `cod54` tests).
- **Disable cleanliness.** Disabling removes the statusLine from active sessions; a brand-new session created by a *stale* client could re-add it (chip still hidden, footer benign). Fully server-authoritative create-time injection (read the setting server-side instead of the payload flag) would close this — deferred.
## Verification appendix — how the schema was captured (reproducible)
Captured without touching global settings or any real session:
1. Throwaway dir `/tmp/sl-capture` with an exporter `dump.sh` that appends stdin to `payloads.jsonl` and prints `cap`; a `settings.json` pointing `statusLine.command` at it.
2. `--print` mode does **not** render a statusline → no capture (confirms TUI-only). Must use interactive.
3. Launch interactive Claude in an **isolated tmux socket** (`tmux -L slcap`, never `-L codeman`) inside the temp dir, `--settings /tmp/sl-capture/settings.json` (no global mutation). Confirm the workspace-trust dialog (appears even with `--dangerously-skip-permissions`), then send a one-line prompt (literal text + Enter separately, Ink-style).
4. After the first response, `rate_limits` appears in the **second** captured record (absent in the first). Inspect with `jq '.rate_limits'`.
5. Tear down: `tmux -L slcap kill-server` + `rm -rf /tmp/sl-capture`; verify the `codeman` socket is untouched.
Related: `docs/claude-code-hooks-reference.md` (hook callback pattern), `src/usage-limit-patterns.ts` (reactive fallback), `docs/respawn-state-machine.md` (auto-resume interplay).
+79
View File
@@ -0,0 +1,79 @@
# Versioning & Stability Policy
Codeman follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`),
managed via `@changesets/cli` (see the COM workflow in `CLAUDE.md`).
This document defines **what the version number actually promises** — i.e. which
surfaces are covered by SemVer and which are explicitly not. It exists because
"1.0" is a commitment to stability, and an undocumented public surface invites
incompatible client assumptions we would then be pressured to keep.
> **Status:** finalized for the 1.0 cut. The HTTP/SSE API **is** part of the stable
> surface — served under `/api/v1` with a uniform response envelope and
> conventional HTTP status codes. See [`api-reference.md`](api-reference.md).
## What SemVer covers (the public, stable surface)
A **MAJOR** bump is required to break any of these after 1.0:
1. **The CLI.** Command names, documented flags, and their behavior for
`codeman <command>` (published to npm as `aicodeman`; invoked as `codeman`).
This is the package's actual public entry point (`bin`).
- The package is published to npm as `aicodeman` and installs **both** the
`aicodeman` and `codeman` commands (`bin` aliases); `codeman` is the
canonical command used throughout the docs. Renaming either after 1.0 is a
breaking change.
2. **The published `xterm-zerolag-input` library**, but on **its own version
line** — it is versioned and released independently of the Codeman app. Its
1.0 status is a separate decision; the Codeman app reaching 1.0 does *not*
imply `xterm-zerolag-input` is 1.0.
3. **Documented environment variables** that configure deployment:
`CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`,
`CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`,
`CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags.
Removing or changing the meaning of one of these is breaking.
4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the
uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and
conventional HTTP status codes. Endpoint paths, the response envelope, error
`errorCode` values, and SSE event names are stable — see
[`api-reference.md`](api-reference.md). *Additive* changes (new endpoints, new
optional fields, new error codes, new SSE events) are non-breaking; breaking
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
is kept working for the bundled UI.
## What SemVer does NOT cover (internal surfaces — may change in any release)
These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
1. **The `~/.codeman/` state file formats** (`state.json`, `settings.json`,
`mux-sessions.json`, etc.). We make a **best-effort** to migrate existing data
forward (and have done so across renames), but the on-disk schema is not a
stable contract — do not write tooling that depends on its exact shape.
2. **Internal TypeScript modules.** The npm package is CLI-only; `import`ing it
programmatically is not supported (there is no stable library entry point).
3. **Experimental / opt-in features**, regardless of the app's version:
Gesture Control (beta), Agent Teams
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
in the UI or docs. These may change or be removed at any time.
## Deprecation policy
When we need to change a covered surface:
- Prefer **additive** changes (new flag/env var/command) over breaking ones.
- A covered surface slated for removal is **deprecated first** — it keeps working
for at least one MINOR release with a runtime warning and a `CHANGELOG.md` note
pointing to the replacement — then removed in the next MAJOR.
- Back-compat migration shims (e.g. the historical Claudeman→Codeman data/socket
migration) are kept until a MAJOR boundary, then may be dropped.
## Pre-1.0 (`0.x`) caveat
Until 1.0 ships, **any release may contain breaking changes** per SemVer's `0.x`
allowance. The commitments above take effect at `1.0.0`.
## See also
- `CLAUDE.md` — the COM release workflow (changesets, version bump, deploy)
- `SECURITY.md` — security reporting and the supported-version policy
- `docs/security-architecture.md` — the full trust model
+12 -4
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.9.7",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.9.7",
"version": "1.1.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -20,6 +20,7 @@
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
@@ -34,7 +35,8 @@
"zod": "^4.3.6"
},
"bin": {
"aicodeman": "dist/index.js"
"aicodeman": "dist/index.js",
"codeman": "dist/index.js"
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
@@ -64,7 +66,7 @@
"vitest": "^4.1.8"
},
"engines": {
"node": ">=18.0.0"
"node": ">=22.0.0"
},
"optionalDependencies": {
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
@@ -4524,6 +4526,12 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-serialize": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
+8 -4
View File
@@ -1,12 +1,13 @@
{
"name": "aicodeman",
"version": "0.9.7",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"version": "1.1.9",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"aicodeman": "./dist/index.js"
"aicodeman": "./dist/index.js",
"codeman": "./dist/index.js"
},
"scripts": {
"postinstall": "node scripts/postinstall.js",
@@ -19,6 +20,8 @@
"test": "vitest run --config config/vitest.config.ts",
"test:watch": "vitest --config config/vitest.config.ts",
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
"test:ci": "vitest run --config config/vitest.ci.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
@@ -58,6 +61,7 @@
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
@@ -117,7 +121,7 @@
}
},
"engines": {
"node": ">=18.0.0"
"node": ">=22.0.0"
},
"repository": {
"type": "git",
+1 -1
View File
@@ -39,7 +39,7 @@ Server echoes 'h' ←───────────────────
## Origin
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), the missing control plane for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code and OpenCode. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
## Install
+3
View File
@@ -45,6 +45,7 @@ run('copy template', 'cp src/templates/case-template.md dist/templates/');
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js');
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
@@ -66,6 +67,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
@@ -89,6 +91,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
'sanitize-html.js',
'app.js',
'terminal-ui.js',
'respawn-ui.js',
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* capture-readme-real.mjs
*
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
* NOT prod itself (never touch prod's live sessions).
*
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
* - per-device localStorage seeding so the capture matches a real device.
*
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
*/
import { chromium } from 'playwright';
import { mkdirSync } from 'fs';
import { join } from 'path';
const BASE = process.env.BASE || 'http://localhost:5000';
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
const SKIN = process.env.SKIN || 'daylight-blue';
const SCENE = process.env.SCENE || 'all';
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
const DSF = Number(process.env.DSF || 2);
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const url = (extra = '') => {
const sep = BASE.includes('?') ? '&' : '?';
const params = [];
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
if (extra) params.push(extra);
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
};
async function newCtx(browser) {
const context = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: DSF,
ignoreHTTPSErrors: BASE.startsWith('https'),
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
await page.addInitScript(
([skin, planUsage, font]) => {
try {
localStorage.setItem('codeman:skin', skin);
localStorage.setItem('codeman-font-size', String(font));
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
if (planUsage) blob.showPlanUsageLimits = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
} catch {
/* ignore */
}
},
[SKIN, PLAN_USAGE, FONT]
);
return { context, page };
}
async function bootstrap(page) {
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
await sleep(1200);
}
async function listSessions(page) {
return page.evaluate(() =>
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
);
}
async function shoot(page, name) {
const out = join(OUT, name);
await page.evaluate((f) => {
try {
if (window.app.setFontSize) window.app.setFontSize(f);
} catch {}
try {
window.app.fitAddon && window.app.fitAddon.fit();
} catch {}
try {
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
} catch {}
}, FONT);
await sleep(1500);
await page.screenshot({ path: out, fullPage: false });
console.log(' Saved: ' + out);
}
async function sceneDashboard(browser) {
console.log('Scene: dashboard');
const { context, page } = await newCtx(browser);
await page.goto(url(), { waitUntil: 'domcontentloaded' });
await bootstrap(page);
const sessions = await listSessions(page);
// Select a claude session so the active terminal shows rich content; all tabs render.
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
await sleep(4000);
await shoot(page, 'multi-session-dashboard.png');
await context.close();
}
async function sceneMonitor(browser) {
console.log('Scene: monitor');
const { context, page } = await newCtx(browser);
await page.goto(url(), { waitUntil: 'domcontentloaded' });
await bootstrap(page);
const sessions = await listSessions(page);
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
await sleep(2500);
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
// mux sessions (/api/mux), starts stats, and renders the task panel.
await page.evaluate(async () => {
try {
await window.app.toggleMonitorPanel();
} catch {}
});
await sleep(3000);
await shoot(page, 'multi-session-monitor.png');
await context.close();
}
async function sceneSubagent(browser) {
console.log('Scene: subagent');
const { context, page } = await newCtx(browser);
await page.goto(url(), { waitUntil: 'domcontentloaded' });
await bootstrap(page);
// Select the session whose subagents we want (subagentActiveTabOnly means
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
const sessions = await listSessions(page);
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
let agents = [];
for (let i = 0; i < 25; i++) {
agents = await page.evaluate(() =>
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
);
if (agents.length >= 1) break;
await sleep(1000);
}
console.log(' live in-browser subagents:', JSON.stringify(agents));
if (agents.length === 0) {
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
await context.close();
return;
}
await page.evaluate(
(ids) => {
ids.slice(0, 2).forEach((id) => {
try {
window.app.openSubagentWindow(id);
} catch {}
});
},
agents.map((a) => a.id)
);
await sleep(2000);
await page.evaluate(() => {
const wins = Array.from(window.app.subagentWindows.values());
const place = [
{ left: 360, top: 60, w: 430, h: 330 },
{ left: 810, top: 60, w: 430, h: 330 },
];
wins.slice(0, 2).forEach((win, i) => {
const el = win.element;
const p = place[i];
el.style.left = p.left + 'px';
el.style.top = p.top + 'px';
el.style.width = p.w + 'px';
el.style.height = p.h + 'px';
});
});
await sleep(1500);
await shoot(page, 'subagent-spawn.png');
await context.close();
}
async function main() {
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
await browser.close();
}
main().catch((e) => {
console.error('FATAL', e.message);
process.exit(1);
});
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* capture-real-overview.mjs
*
* Captures a REAL claude-overview screenshot from a LIVE Codeman server
* (no mock injection). Drive a real session to do real work, then run:
*
* SID=<sessionId> BASE=http://localhost:5000 OUT=screenshots-real \
* node scripts/capture-real-overview.mjs
*
* Skin defaults to daylight-blue (prod default) via the localStorage pre-paint
* contract in index.html. Output: <OUT>/claude-overview.png at 1280x720 (DSF 2).
*/
import { chromium } from 'playwright';
import { mkdirSync } from 'fs';
import { join } from 'path';
const SID = process.env.SID;
const BASE = process.env.BASE || 'http://localhost:5000';
const OUT = process.env.OUT || 'screenshots-real';
const SKIN = process.env.SKIN || 'daylight-blue';
// Unique filename per run (timestamped) so a viewer holding an old render of a
// fixed path can never shadow a fresh capture. Override with NAME=… if needed.
const STAMP = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
const NAME = process.env.NAME || `claude-overview-${STAMP}.png`;
const VIEWPORT = { width: Number(process.env.VW || 1512), height: Number(process.env.VH || 812) };
// IMPORTANT: default deviceScaleFactor is 1, NOT 2. xterm's WebGL renderer in
// headless Chromium draws terminal glyphs at ~2× their nominal size when DSF=2
// (while still reporting nominal 8px cell dims internally, so it can't be caught
// by measuring terminal.cols/cell — only the pixels reveal it). The HTML chrome
// is unaffected, so DSF=2 makes ONLY the console font look comically large. DSF=1
// renders the console at its true size, matching a real (non-headless) browser.
const DSF = Number(process.env.DSF || 1);
if (!SID) {
console.error('SID env var required (the live session id to screenshot)');
process.exit(1);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const main = async () => {
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
const context = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: DSF,
ignoreHTTPSErrors: BASE.startsWith('https'),
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
// Force the skin before any page script runs (pre-paint <head> contract), and
// seed the PER-DEVICE display blob so the capture reflects what prod actually
// shows on the user's real device — notably the plan-usage chip, which is a
// per-device setting (default OFF) deleted from the server payload, so a fresh
// browser would otherwise hide it. PLAN_USAGE=0 disables.
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
// Terminal console font size. App default is 14px; a fresh headless browser has
// no saved codeman-font-size, so it renders at 14 — much larger than a real
// device where the console has been zoomed down. Seed a smaller value (clamped
// to the app's [10,24] range) so the console font looks normal in the capture.
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 14)));
await page.addInitScript(
([skin, planUsage, font]) => {
try {
localStorage.setItem('codeman:skin', skin);
localStorage.setItem('codeman-font-size', String(font));
// Desktop app-settings blob (settings-ui.js getSettingsStorageKey()).
// Present these display keys explicitly so the server merge won't seed
// side panels open (display keys only seed from server when absent from
// localStorage). Matches the clean full-width-terminal reference look.
const blob = {
skin,
showFileBrowser: false,
showMonitor: false,
showSubagents: false,
showProjectInsights: false,
};
if (planUsage) blob.showPlanUsageLimits = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
} catch {
/* ignore */
}
},
[SKIN, PLAN_USAGE, FONT]
);
// At DSF>1, xterm's WebGL renderer draws glyphs at ~2x (see DSF comment above).
// The app honors a `?nowebgl` URL param that switches to xterm's DOM renderer,
// which respects devicePixelRatio correctly — so DSF=2 + nowebgl yields a crisp
// 2x (retina) capture at the TRUE font size. Auto-enable it whenever DSF>1.
const url = DSF > 1 ? `${BASE}${BASE.includes('?') ? '&' : '?'}nowebgl` : BASE;
console.log(`Loading ${url} (DSF=${DSF}) ...`);
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
await sleep(1500);
console.log(`Selecting session ${SID} ...`);
await page.evaluate((sid) => window.app.selectSession(sid), SID);
// Let the terminal buffer stream in + xterm render + any Ink redraw settle.
await sleep(2000);
// Force a clean fit (avoids capturing a transient pre-fit frame where the
// terminal renders at the wrong column count) and re-apply per-device header
// visibility so the seeded plan-usage chip is shown.
await page.evaluate((font) => {
// Force the console font explicitly (setFontSize also re-fits) in case
// loadFontSize didn't pick up the seeded value before the session rendered.
try {
if (window.app.setFontSize) window.app.setFontSize(font);
else window.app.terminal.options.fontSize = font;
} catch {}
try {
window.app.fitAddon && window.app.fitAddon.fit();
} catch {}
try {
window.dispatchEvent(new Event('resize'));
} catch {}
try {
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
} catch {}
}, FONT);
await sleep(3000);
// Optionally scroll the terminal up to frame the rich tool-call region
// (Read/Write/Bash + green test results) instead of the trailing summary.
const SCROLL = Number(process.env.SCROLL || 0);
if (SCROLL) {
await page.evaluate((n) => {
const t = window.app && window.app.terminal;
if (t && t.scrollLines) t.scrollLines(-n);
}, SCROLL);
await sleep(800);
}
const outPath = join(OUT, NAME);
await page.screenshot({ path: outPath, fullPage: false });
console.log(`Saved: ${outPath}`);
await context.close();
await browser.close();
};
main().catch((e) => {
console.error('FATAL', e.message);
process.exit(1);
});
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
/**
* Frontend JS syntax check.
*
* CI's `npm run lint` only lints TypeScript under src/, and `tsc` excludes the
* frontend — so a plain SyntaxError in a shipped `src/web/public` script (loaded
* as a bare <script>, no bundler) passes CI green yet breaks the whole module at
* load.
* (This is exactly how PR #112's duplicate-`const` error in session-ui.js slipped
* through.) This runs `node --check` (parse-only; browser globals don't matter)
* on every shipped frontend script so that class of bug fails fast.
*/
import { readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC_DIR = join(ROOT, 'src', 'web', 'public');
const files = readdirSync(PUBLIC_DIR)
.filter((f) => f.endsWith('.js'))
.map((f) => join(PUBLIC_DIR, f));
let failed = 0;
for (const file of files) {
try {
execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
} catch (err) {
failed++;
const msg = err.stderr ? err.stderr.toString() : String(err);
console.error(`✗ syntax error in ${file.replace(ROOT + '/', '')}:\n${msg}`);
}
}
if (failed > 0) {
console.error(`\n${failed} frontend file(s) failed the syntax check.`);
process.exit(1);
}
console.log(`✓ ${files.length} frontend JS files parse cleanly`);
+3
View File
@@ -252,6 +252,7 @@ if (isGlobalInstall) {
const require = createRequire(import.meta.url);
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
const serializeDir = join(require.resolve('@xterm/addon-serialize'), '..', '..');
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
@@ -264,12 +265,14 @@ if (isGlobalInstall) {
try {
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(serializeDir, 'lib', 'addon-serialize.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-serialize.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
} catch {
// Fallback: copy unminified
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(serializeDir, 'lib', 'addon-serialize.js'), join(vendorDir, 'xterm-addon-serialize.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
}
+15
View File
@@ -31,6 +31,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
REPO=""
TAG=""
SUPERVISOR="none"
SERVER_PID=""
STATUS_FILE=""
UPDATE_ID=""
FROM_VERSION=""
@@ -50,6 +51,7 @@ while [[ $# -gt 0 ]]; do
--node) NODE="$2"; shift 2 ;;
--log) LOG="$2"; shift 2 ;;
--prev-sha) PREV_SHA="$2"; shift 2 ;;
--server-pid) SERVER_PID="$2"; shift 2 ;;
--stash) DO_STASH=1; shift ;;
*) shift ;;
esac
@@ -198,6 +200,19 @@ case "$SUPERVISOR" in
|| fail "Build succeeded but launchd restart failed" "launchctl"
}
;;
launchd-daemon)
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
# domain needs root, but we don't need it — kill the server and launchd
# respawns it on the new dist/ within ThrottleInterval seconds.
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
: # respawn is launchd's job from here
else
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
echo "[self-update] launchd-daemon: could not signal server pid '$SERVER_PID' — manual restart required"
exit 0
fi
;;
*)
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview Parses terminal magic links that request attachment cards.
*/
import { isAbsolute } from 'node:path';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
export function parseAttachmentMagicLinks(data: string): string[] {
const results: string[] = [];
const seen = new Set<string>();
for (const match of data.matchAll(MAGIC_LINK_RE)) {
const query = trimTrailingPunctuation(match[1] || '');
try {
const params = new URLSearchParams(query);
const filePath = params.get('path');
if (!filePath || !isAbsolute(filePath)) continue;
const extension = filePath.split('.').pop()?.toLowerCase() || '';
if (!isSupportedAttachmentExtension(extension)) continue;
if (seen.has(filePath)) continue;
seen.add(filePath);
results.push(filePath);
} catch {
// Ignore malformed terminal text. Magic links are advisory.
}
}
return results;
}
function trimTrailingPunctuation(value: string): string {
return value.replace(/[),.;:]+$/g, '');
}
+237
View File
@@ -0,0 +1,237 @@
/**
* @fileoverview In-memory attachment registry for live external document references.
*
* Session-local files keep using the existing workspace-scoped file routes. This
* registry is only for explicit, live external attachments that need a stable ID
* so browser requests never contain arbitrary absolute paths.
*/
import { randomUUID } from 'node:crypto';
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 { validateSessionFilePath } from './web/route-helpers.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
export type AttachmentSource = 'detected' | 'external';
export interface AttachmentRecord {
attachmentId: string;
sessionId: string;
filePath: string;
fileName: string;
extension: string;
attachmentType: AttachmentDetectedType;
size: number;
mtimeMs: number;
timestamp: number;
source: AttachmentSource;
}
export interface AttachmentRegistrationResult extends AttachmentDetectedEvent {
attachmentId: string;
source: AttachmentSource;
rawUrl: string;
previewUrl: string;
thumbnailUrl: string;
}
export class AttachmentRegistrationError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
}
}
/** Per-session attachment cap. Bounds memory against a client (or a
* prompt-injected magic-link flood) registering unbounded distinct paths. */
const MAX_ATTACHMENTS_PER_SESSION = 200;
class AttachmentRegistry {
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
register(record: AttachmentRecord): void {
let records = this.recordsBySession.get(record.sessionId);
if (!records) {
records = new Map();
this.recordsBySession.set(record.sessionId, records);
}
records.set(record.attachmentId, record);
// Evict oldest (insertion-order) entries beyond the cap.
while (records.size > MAX_ATTACHMENTS_PER_SESSION) {
const oldest = records.keys().next().value;
if (oldest === undefined) break;
records.delete(oldest);
}
}
get(sessionId: string, attachmentId: string): AttachmentRecord | undefined {
return this.recordsBySession.get(sessionId)?.get(attachmentId);
}
findByFilePath(sessionId: string, filePath: string): AttachmentRecord | undefined {
const records = this.recordsBySession.get(sessionId);
if (!records) return undefined;
for (const record of records.values()) {
if (record.filePath === filePath) return record;
}
return undefined;
}
clearSession(sessionId: string): void {
this.recordsBySession.delete(sessionId);
}
}
export const attachmentRegistry = new AttachmentRegistry();
export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
}
export function getAttachmentType(extension: string): AttachmentDetectedType {
const normalized = extension.toLowerCase().replace(/^\./, '');
if (normalized === 'png') return 'image';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'md') return 'markdown';
if (normalized === 'txt') return 'text';
return 'document';
}
export function buildAttachmentRoutes(
sessionId: string,
attachmentId: string
): {
rawUrl: string;
previewUrl: string;
thumbnailUrl: string;
} {
const encodedId = encodeURIComponent(attachmentId);
return {
rawUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/raw`,
previewUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/preview`,
thumbnailUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/thumbnail`,
};
}
export function buildFileThumbnailRoute(sessionId: string, relativePath: string): string {
return `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(relativePath)}`;
}
export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentRegistrationResult {
const routes = buildAttachmentRoutes(record.sessionId, record.attachmentId);
return {
sessionId: record.sessionId,
filePath: record.fileName,
relativePath: '',
fileName: record.fileName,
extension: record.extension,
attachmentType: record.attachmentType,
timestamp: record.timestamp,
size: record.size,
attachmentId: record.attachmentId,
source: record.source,
...routes,
};
}
/** Options for {@link registerExternalAttachment}. */
export interface RegisterExternalAttachmentOptions {
/**
* The registering session's working directory. Required to enforce workspace
* confinement — either when the global mode is enabled
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) or when
* {@link forceWorkspaceConfinement} is set for this call.
*/
sessionWorkingDir?: string;
/**
* Force workspace confinement for THIS registration regardless of the global
* setting. Used by the terminal-output `codeman://attach` magic-link scanner:
* terminal output is attacker-influenceable (a prompt-injected session can
* print an arbitrary path), so passive magic links may only reference files
* inside the session workspace. Deliberate cross-workspace attachment still
* works through the explicit, Origin-guarded `POST /attachments` route and the
* `codeman attach` CLI (which POSTs directly when a session id is known).
*/
forceWorkspaceConfinement?: boolean;
}
export async function registerExternalAttachment(
sessionId: string,
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
let resolvedPath: string;
try {
resolvedPath = realpathSync(requestedPath);
} catch {
throw new AttachmentRegistrationError('Attachment file not found', 404);
}
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || options.forceWorkspaceConfinement) {
// Workspace-confined: the file MUST resolve inside the session's workspace.
// Applies when the global strict mode is on (opt-in, default OFF) OR when
// the caller forces it for this registration (the magic-link scanner — see
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
const workingDir = options.sessionWorkingDir;
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
}
// Blocklist (DEFAULT, also applied alongside confinement as defense in
// depth): pre-populated secret locations + the /root and /etc trees + any
// operator-configured extra trees. Symlinks are already resolved above.
// Cross-workspace attachment of non-blocked files stays allowed, so
// codeman-publish and the ~/.codeman review loop keep working.
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
if (!isSupportedAttachmentExtension(extension)) {
throw new AttachmentRegistrationError('Unsupported attachment type');
}
const stat = await fs.stat(resolvedPath);
if (typeof stat.isFile === 'function' && !stat.isFile()) {
throw new AttachmentRegistrationError('Attachment path is not a file');
}
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
if (existing) {
existing.size = stat.size;
existing.mtimeMs = stat.mtimeMs ?? 0;
existing.timestamp = Date.now();
return attachmentRecordToEvent(existing);
}
const record: AttachmentRecord = {
attachmentId: `att_${randomUUID()}`,
sessionId,
filePath: resolvedPath,
fileName: basename(resolvedPath),
extension,
attachmentType: getAttachmentType(extension),
size: stat.size,
mtimeMs: stat.mtimeMs ?? 0,
timestamp: Date.now(),
source: 'external',
};
attachmentRegistry.register(record);
return attachmentRecordToEvent(record);
}
+123
View File
@@ -10,11 +10,17 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { createRequire } from 'module';
import http from 'node:http';
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { dataPath } from './config/instance.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js';
import { getErrorMessage } from './types.js';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { version: string };
@@ -23,6 +29,93 @@ const program = new Command();
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (password) {
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'POST',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
},
(res) => {
res.resume();
res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300)));
}
);
req.on('error', () => resolve(false));
req.write(body);
req.end();
});
}
program
.command('attach <path>')
.description('Show an attachment card for a local file')
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)')
.action(async (filePath, options) => {
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
process.exit(1);
}
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
console.log(chalk.green('✓ Attachment card requested'));
return;
}
console.log(makeAttachmentMagicLink(filePath));
});
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -533,4 +626,34 @@ program
}
});
program
.command('doctor')
.alias('check-deps')
.description('Check Codeman tool dependencies (Node, Claude CLI, tmux, LibreOffice, MS Office)')
.option('--json', 'Output structured JSON instead of a table')
.option('--category <name>', 'Only check one category (core|office|other)')
.action(async (options) => {
const { createRealHost, checkAll } = await import('./utils/dependency-checker.js');
const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js');
const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) {
console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`);
process.exit(2);
}
const host = createRealHost();
const registry = options.category
? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category)
: DEPENDENCY_REGISTRY;
const results = checkAll(registry, host);
if (options.json) {
console.log(JSON.stringify(renderJson(results, host.environment), null, 2));
} else {
console.log(renderTable(results, host.environment));
}
process.exit(computeExitCode(results));
});
export { program };
+133
View File
@@ -0,0 +1,133 @@
/**
* @fileoverview Attachment path-guard configuration (COD-53).
*
* Governs which host files may be registered as cross-workspace attachments
* and served to the browser. Two operator-facing knobs, both with safe
* defaults:
*
* 1. **Blocked-path blocklist (DEFAULT, configurable).** Pre-populated with the
* shared secret-location blocklist (`isSensitivePath`) PLUS the directory
* trees `/root` and `/etc` (anything under them is blocked). The operator
* EXTENDS — never shrinks — this set with additional absolute directory
* trees via the settings key `attachmentBlockedPaths: string[]` and/or the
* env var `CODEMAN_ATTACHMENT_BLOCKED_PATHS` (comma-separated).
*
* 2. **Workspace confinement (OPTIONAL, default OFF).** When enabled, an
* attachment must resolve INSIDE the registering session's workingDir
* (reusing `validateSessionFilePath` containment semantics). This is
* strictly more restrictive than the blocklist and breaks intentional
* cross-workspace attachment (codeman-publish, the ~/.codeman review-card
* loop), so it is OFF by default. Toggle via settings
* `attachmentConfineToWorkspace: boolean` and/or env
* `CODEMAN_ATTACHMENT_CONFINE` (`1`/`true`).
*
* All paths passed to the predicates here MUST be absolute and symlink-resolved
* (realpath) by the caller, mirroring `isSensitivePath`'s contract.
*
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
/**
* Directory trees blocked by default, IN ADDITION to the secret-location
* blocklist in `isSensitivePath`. Anything resolving under one of these trees
* is rejected. Pre-populated with the root account home and the system config
* tree (which already partially overlaps `isSensitivePath`'s `/etc/shadow`
* etc., but here we block the WHOLE tree).
*/
export const DEFAULT_BLOCKED_TREES: readonly string[] = ['/root', '/etc'];
/** Settings key carrying extra blocked directory trees (extends the defaults). */
export const ATTACHMENT_BLOCKED_PATHS_SETTING = 'attachmentBlockedPaths';
/** Settings key carrying the workspace-confinement toggle. */
export const ATTACHMENT_CONFINE_SETTING = 'attachmentConfineToWorkspace';
/** Resolved attachment-guard configuration. */
export interface AttachmentGuardConfig {
/** Pre-populated default trees PLUS any operator extras. */
blockedTrees: string[];
/** Whether attachments must resolve inside the session workspace. */
confineToWorkspace: boolean;
}
/** Normalizes a tree prefix: trim, drop trailing separators (but keep root). */
function normalizeTree(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
// Strip trailing slashes so '/etc/' and '/etc' behave the same; never reduce
// a bare separator to empty.
const stripped = trimmed.replace(/[/\\]+$/, '');
return stripped || trimmed[0];
}
/**
* Returns true if `absPath` (absolute, symlink-resolved) is the tree itself or
* lives under it. Uses path-separator-aware matching so `/etc` does NOT block
* an unrelated `/etcetera/notes.md`.
*/
export function isUnderTree(absPath: string, tree: string): boolean {
const t = normalizeTree(tree);
if (!t) return false;
if (absPath === t) return true;
return absPath.startsWith(t.endsWith(sep) ? t : t + sep);
}
/** Parses the comma-separated env override into a list of normalized trees. */
function parseEnvBlockedTrees(): string[] {
const raw = process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
if (!raw) return [];
return raw
.split(',')
.map(normalizeTree)
.filter((t) => t.length > 0);
}
/** Parses the env confinement toggle (`1`/`true`/`yes`/`on`, case-insensitive). */
function parseEnvConfine(): boolean | undefined {
const raw = process.env.CODEMAN_ATTACHMENT_CONFINE;
if (raw === undefined) return undefined;
return /^(1|true|yes|on)$/i.test(raw.trim());
}
/**
* Loads the effective attachment-guard config by merging the pre-populated
* defaults with settings.json and env overrides. Env wins over settings for the
* confinement toggle; blocked-tree extras from BOTH sources are unioned on top
* of the defaults (operators can only EXTEND, never shrink, the blocked set).
*/
export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const settingsTrees = Array.isArray(settings[ATTACHMENT_BLOCKED_PATHS_SETTING])
? (settings[ATTACHMENT_BLOCKED_PATHS_SETTING] as unknown[])
.filter((v): v is string => typeof v === 'string')
.map(normalizeTree)
.filter((t) => t.length > 0)
: [];
const blockedTrees = Array.from(new Set([...DEFAULT_BLOCKED_TREES, ...settingsTrees, ...parseEnvBlockedTrees()]));
const envConfine = parseEnvConfine();
const settingsConfine = settings[ATTACHMENT_CONFINE_SETTING] === true;
const confineToWorkspace = envConfine ?? settingsConfine;
return { blockedTrees, confineToWorkspace };
}
/**
* Attachment-specific blocklist check. Builds on the shared `isSensitivePath`
* base (secret locations, shared with `/api/download`) and ADDS the configured
* directory trees (`/root`, `/etc`, plus operator extras). `absPath` must be
* absolute and symlink-resolved.
*
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
* behavior is NOT changed — only attachment registration/serving uses this.
*/
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @fileoverview Static registry of downstream tool dependencies probed by
* `codeman doctor`. Each entry declares per-environment resolvers and the
* skills that use it. EXTENSION POINT: skill-manifest-driven discovery
* (COD follow-up) will merge dynamically-found entries into this list.
*
* @module config/dependency-registry
*/
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
/** The valid `--category` filter values; single source of truth for the type, the CLI
* help text, and CLI input validation. */
export const TOOL_CATEGORIES = ['core', 'office', 'other'] as const;
export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
/** Resolve a binary on the PATH and read its version. */
export interface PathResolver {
kind: 'path';
bins: string[];
versionArg?: string; // default '--version'
versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)?
}
/** Resolve a Windows-installed app reachable from win32 or WSL. */
export interface WindowsSideResolver {
kind: 'windows-side';
appDirs: string[]; // relative to a Program Files root
exes: string[]; // candidate executables; first found wins
}
export interface ResolverSpec {
match: ProbeEnvironment[];
resolver: PathResolver | WindowsSideResolver;
}
export interface ToolDependency {
id: string;
label: string;
category: ToolCategory;
required: boolean;
usedBy?: string[];
minVersion?: string;
resolvers: ResolverSpec[];
installHint?: Partial<Record<ProbeEnvironment, string>>;
}
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
{
id: 'claude',
label: 'Claude CLI',
category: 'core',
required: false,
usedBy: ['Claude Code sessions (default backend)'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
{
id: 'opencode',
label: 'OpenCode CLI',
category: 'core',
required: false,
usedBy: ['OpenCode sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
},
{
id: 'codex',
label: 'Codex CLI',
category: 'core',
required: false,
usedBy: ['Codex sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
},
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
},
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
},
},
],
},
];
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Per-instance shared hook secret (COD-54).
*
* Claude Code hooks POST to `/api/hook-event` with no Basic-Auth credentials,
* relying on a localhost bypass in `web/middleware/auth.ts`. That bypass is safe
* for loopback-only deploys, but a `cloudflared --url http://127.0.0.1:port`
* tunnel proxies internet traffic INTO the loopback origin, so tunneled requests
* arrive with `req.ip === 127.0.0.1` and would otherwise pass the bypass and
* drive respawn/Ralph signals unauthenticated.
*
* To close that hole WITHOUT breaking the loop's own (credential-less) hook
* channel, every locally-generated hook command now presents a per-instance
* shared secret in the `X-Codeman-Hook-Secret` header. The middleware requires
* a matching secret for the bypass WHEN A TUNNEL IS RUNNING. Tunneled internet
* traffic can't know the secret; local hooks (which we generate) do.
*
* Storage mirrors the VAPID-key pattern in `push-store.ts`: a small file under
* the instance data dir (`dataPath('hook-secret')`), read-if-present /
* generate-if-missing, stable across restarts. 256 bits of hex.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { getDataDir, dataPath } from './instance.js';
/** HTTP header local hooks use to present the shared secret. */
export const HOOK_SECRET_HEADER = 'X-Codeman-Hook-Secret';
/** Number of random bytes in the secret (256 bits → 64 hex chars). */
const SECRET_BYTES = 32;
let cachedSecret: string | null = null;
/**
* Return this instance's hook secret, generating and persisting it on first use.
* Stable across restarts. Cached in-process after the first read.
*/
export function getHookSecret(): string {
if (cachedSecret) return cachedSecret;
const secretFile = dataPath('hook-secret');
if (existsSync(secretFile)) {
try {
const raw = readFileSync(secretFile, 'utf-8').trim();
if (raw) {
cachedSecret = raw;
return cachedSecret;
}
// Empty/whitespace file — fall through and regenerate.
} catch {
// Unreadable — fall through and regenerate.
}
}
const secret = randomBytes(SECRET_BYTES).toString('hex');
try {
mkdirSync(getDataDir(), { recursive: true });
// Owner-only perms — the secret gates the hook bypass.
writeFileSync(secretFile, secret, { mode: 0o600 });
} catch {
// Best-effort persistence: even if the write fails we still return a usable
// secret for this process so hooks/middleware agree within this run.
}
cachedSecret = secret;
return cachedSecret;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* @fileoverview Workflow (ultracode) run-watcher polling and cache configuration.
*
* Controls how frequently WorkflowRunWatcher polls
* ~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json
* and how many runs are cached in memory.
*
* Distinct from the Agent-Teams config (team-config.ts). The run-state JSON is
* rewritten on every agent tick across a whole run (28+ agents), so the watcher
* relies on a per-file mtime skip; the poll itself is just N stat() calls.
*
* @module config/workflow-config
*/
/** Workflow run-state poll interval (ms). Short because a poll is just N mtime stats. */
export const WORKFLOW_RUN_POLL_INTERVAL_MS = 10_000;
/** Max cached workflow runs (LRU eviction). */
export const MAX_CACHED_WORKFLOW_RUNS = 100;
/**
* Default recency window (minutes) for getRecentRuns(). Generous enough that a
* recently-finished long run still appears in the LEFT-pane list — filtered on
* last-activity, not start time, so multi-hour runs don't vanish.
*/
export const WORKFLOW_RUN_RECENT_WINDOW_MIN = 240;
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Global concurrency limiter for spawning external document
* converters (pdftoppm / LibreOffice `soffice` / Word-COM `powershell.exe`).
*
* Without a cap, N simultaneous thumbnail/preview requests for *distinct*
* documents fork N converter processes at once — each held open for up to the
* multi-minute conversion timeout. That is a localhost resource-exhaustion
* (fork-bomb-shaped) vector: a handful of large PDFs detected at once can pin
* CPU and RAM. This module serializes converter spawns down to a small fixed
* pool; excess spawns queue (FIFO) until a slot frees. The in-flight cache in
* `document-preview-cache.ts` already de-dups *identical* inputs; this bounds
* the *distinct* case the cache can't.
*
* Permit accounting transfers the slot directly to the next waiter on release
* (rather than decrement-then-reacquire) so the active count can never exceed
* the cap even under interleaved async resumption.
*
* NOT re-entrant: never call `runWithConversionLimit` from inside a task that is
* already holding a slot — a nested acquire under a full pool would deadlock.
* The converter call sites only ever acquire once per request (the office path
* acquires for `soffice` and `pdftoppm` sequentially, not nested).
*/
/**
* Max converter processes allowed to run concurrently across the whole process.
* Override with CODEMAN_MAX_DOCUMENT_CONVERSIONS (clamped to >= 1).
*/
const MAX_CONCURRENT_DOCUMENT_CONVERSIONS = (() => {
const raw = Number(process.env.CODEMAN_MAX_DOCUMENT_CONVERSIONS);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
})();
let active = 0;
const waiters: Array<() => void> = [];
/** Test/diagnostic hook: converters currently holding a slot. */
export function getActiveConversionCount(): number {
return active;
}
function acquire(): Promise<void> {
if (active < MAX_CONCURRENT_DOCUMENT_CONVERSIONS) {
active++;
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
}
function release(): void {
const next = waiters.shift();
if (next) {
// Hand the slot straight to the next waiter — `active` stays at the cap.
next();
} else {
active--;
}
}
/** Run `task` once a converter slot is free, releasing the slot afterward. */
export async function runWithConversionLimit<T>(task: () => Promise<T>): Promise<T> {
await acquire();
try {
return await task();
} finally {
release();
}
}
+308
View File
@@ -0,0 +1,308 @@
/**
* @fileoverview Shared disk cache for expensive Office document previews.
*/
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, extname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { runWithConversionLimit } from './document-conversion-limiter.js';
const execFileAsync = promisify(execFile);
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
/**
* Cap on persistent converted-PDF files kept in DOCUMENT_PREVIEW_CACHE_DIR.
* The cache key embeds the source mtime, so every edit to a doc orphans its
* prior PDF; without a cap the dir grows unbounded across long-running sessions.
* Override with CODEMAN_MAX_PREVIEW_CACHE_FILES (clamped to >= 1).
*/
const MAX_PREVIEW_CACHE_FILES = (() => {
const raw = Number(process.env.CODEMAN_MAX_PREVIEW_CACHE_FILES);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 100;
})();
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
return `
$ErrorActionPreference = "Stop"
$source = ${toPowerShellSingleQuotedString(sourcePath)}
$output = ${toPowerShellSingleQuotedString(outputPath)}
$word = $null
$doc = $null
try {
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
$doc = $word.Documents.Open($source)
$doc.ExportAsFixedFormat($output, 17)
} finally {
if ($null -ne $doc) {
$doc.Close($false) | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
}
if ($null -ne $word) {
$word.Quit() | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
}
[System.GC]::Collect()
[System.GC]::WaitForPendingFinalizers()
}
`.trim();
}
type OfficePreviewConverter = 'msword' | 'libreoffice';
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
export function clearDocumentPreviewCache(): void {
inFlightOfficeConversions.clear();
}
/**
* Best-effort LRU-ish eviction for the persistent converted-PDF cache: keeps at
* most MAX_PREVIEW_CACHE_FILES `*.pdf` files in `cacheDir`, deleting the oldest
* by mtime once over the cap. Never throws — a pruning failure must not fail the
* conversion that triggered it. Only `*.pdf` files are considered, so the
* transient `work-*` mkdtemp dirs are ignored.
*/
export async function pruneDocumentPreviewCache(cacheDir: string): Promise<void> {
try {
const entries = await fs.readdir(cacheDir);
const pdfs = entries.filter((name) => name.toLowerCase().endsWith('.pdf'));
if (pdfs.length <= MAX_PREVIEW_CACHE_FILES) return;
const stats = await Promise.all(
pdfs.map(async (name) => {
const fullPath = join(cacheDir, name);
try {
const stat = await fs.stat(fullPath);
return { fullPath, mtimeMs: stat.mtimeMs ?? 0 };
} catch {
return null;
}
})
);
const sorted = stats.filter((s): s is { fullPath: string; mtimeMs: number } => s !== null);
sorted.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
const toRemove = sorted.slice(0, Math.max(0, sorted.length - MAX_PREVIEW_CACHE_FILES));
await Promise.all(toRemove.map((entry) => fs.rm(entry.fullPath, { force: true }).catch(() => {})));
} catch {
// Best-effort: pruning must never break a conversion.
}
}
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
const ext = extension.toLowerCase().replace(/^\./, '');
if (ext !== 'docx' && ext !== 'pptx') return null;
let sourceStat;
try {
sourceStat = await fs.stat(filePath);
} catch {
return null;
}
for (const converter of getOfficePreviewConverters(filePath, ext)) {
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
if (await fileExists(cachePath)) {
return cachePath;
}
const inFlightKey = `${converter}:${cacheKey}`;
const inFlight = inFlightOfficeConversions.get(inFlightKey);
if (inFlight) {
const converted = await inFlight;
if (converted) return converted;
continue;
}
const conversion =
converter === 'msword'
? convertWordDocumentToCachedPdf(filePath, cachePath)
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
inFlightOfficeConversions.set(inFlightKey, conversion);
try {
const converted = await conversion;
if (converted) return converted;
} finally {
inFlightOfficeConversions.delete(inFlightKey);
}
}
return null;
}
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
return ['msword', 'libreoffice'];
}
return ['libreoffice'];
}
function createDocumentPreviewCacheKey(
filePath: string,
extension: string,
size: number,
mtimeMs: number,
converter: OfficePreviewConverter
): string {
return createHash('sha256')
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
.digest('hex')
.slice(0, 32);
}
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
if (converter === 'msword') {
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
if (windowsCacheDir) {
return join(windowsCacheDir, `${cacheKey}.pdf`);
}
}
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return typeof stat.isFile !== 'function' || stat.isFile();
} catch {
return false;
}
}
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
const outputPath = wslMountPathToWindowsPath(cachePath);
if (!outputPath) return null;
let sourceCopyPath: string | undefined;
try {
await fs.mkdir(dirname(cachePath), { recursive: true });
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
await fs.copyFile(filePath, sourceCopyPath);
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
if (!sourcePath) return null;
await runWithConversionLimit(() =>
execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
],
{
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
}
)
);
if (await fileExists(cachePath)) {
await pruneDocumentPreviewCache(dirname(cachePath));
return cachePath;
}
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
return null;
} catch (err) {
console.warn(
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
getCacheErrorMessage(err)
);
return null;
} finally {
if (sourceCopyPath) {
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
}
}
}
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
let workDir: string | undefined;
try {
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
const outDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
workDir = outDir;
const profileDir = join(outDir, 'profile');
await fs.mkdir(profileDir, { recursive: true });
await runWithConversionLimit(() =>
execFileAsync(
'soffice',
[
'--headless',
'--nologo',
'--nofirststartwizard',
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
'--convert-to',
'pdf',
'--outdir',
outDir,
filePath,
],
{
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
}
)
);
const converted = (await fs.readdir(outDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
if (!converted) return null;
await fs.rename(join(workDir, converted), cachePath);
await pruneDocumentPreviewCache(DOCUMENT_PREVIEW_CACHE_DIR);
return cachePath;
} catch (err) {
console.warn(
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
getCacheErrorMessage(err)
);
return null;
} finally {
if (workDir) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
}
function wslMountPathToWindowsPath(filePath: string): string | null {
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
if (!match) return null;
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
}
function getWindowsUserTempCacheDir(filePath: string): string | null {
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
if (!match) return null;
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
}
function toPowerShellSingleQuotedString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function encodePowerShellCommand(script: string): string {
return Buffer.from(script, 'utf16le').toString('base64');
}
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
}
function getCacheErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* @fileoverview Best-effort first-page thumbnails for attachment cards.
*/
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, extname, join } from 'node:path';
import { promisify } from 'node:util';
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
import { runWithConversionLimit } from './document-conversion-limiter.js';
const execFileAsync = promisify(execFile);
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
export interface ThumbnailResult {
content: Buffer;
contentType: 'image/png';
}
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
const ext = extension.toLowerCase().replace(/^\./, '');
try {
await fs.stat(filePath);
if (ext === 'png') {
return { content: await fs.readFile(filePath), contentType: 'image/png' };
}
if (ext === 'pdf') {
return renderPdfFirstPage(filePath);
}
if (ext === 'docx' || ext === 'pptx') {
return renderOfficeFirstPage(filePath);
}
} catch (err) {
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
return null;
}
return null;
}
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
try {
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
if (!previewPdfPath) return null;
return await renderPdfFirstPage(previewPdfPath);
} catch (err) {
console.warn(
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
getThumbnailErrorMessage(err)
);
return null;
}
}
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
let previewDir: string | undefined;
try {
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
const prefix = join(previewDir, basename(filePath, extname(filePath)));
await runWithConversionLimit(() =>
execFileAsync('pdftoppm', ['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix], {
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
})
);
const content = await fs.readFile(`${prefix}.png`);
return { content, contentType: 'image/png' };
} catch (err) {
console.warn(
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
getThumbnailErrorMessage(err)
);
return null;
} finally {
if (previewDir) {
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
}
}
}
function getThumbnailErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+6 -2
View File
@@ -395,8 +395,12 @@ export class FileStreamManager extends EventEmitter {
// Normalize the working directory
const normalizedWorkingDir = resolve(workingDir);
// Check if the resolved path is within the working directory
// or common log directories (/tmp intentionally excluded — world-writable)
// Allowed read roots for log tailing: the session working dir plus the
// INTENTIONAL log directories (/var/log, ~/logs). This is wider than the
// per-session boundary used by validateSessionFilePath — a deliberate,
// tested design choice for tailing system/app logs, documented as such in
// docs/security-architecture.md (security review M5). /tmp is excluded
// (world-writable).
const allowedPaths = [normalizedWorkingDir, '/var/log', resolve(homedir(), 'logs')];
const isAllowed = allowedPaths.some((allowed) => {
+212 -73
View File
@@ -3,8 +3,9 @@
*
* Generates `.claude/settings.local.json` with hook definitions that POST
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
* Uses `$CODEMAN_API_URL` and `$CODEMAN_SESSION_ID` env vars (set on every
* managed session) so the config is static per case directory.
* Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
* env vars (set on every managed session) so the config is static per case
* directory and free of secret values.
*
* Key exports:
* - `generateHooksConfig()` — returns hooks object for settings.local.json
@@ -30,6 +31,30 @@ import { join } from 'node:path';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
* writer in this module (hooks, env, model, statusLine) shares this map, so
* concurrent updates to the SAME file — e.g. session-create writing hooks/model
* while an App-Settings toggle injects the statusLine into the same repo — can't
* lose each other's changes through interleaved read-then-write. Per-path chains
* are independent; the map self-prunes when a path's chain goes idle.
*/
const settingsWriteLocks = new Map<string, Promise<unknown>>();
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
// Tail never rejects, so a failed write doesn't poison subsequent writers.
const tail = run.then(
() => {},
() => {}
);
settingsWriteLocks.set(path, tail);
void tail.then(() => {
if (settingsWriteLocks.get(path) === tail) settingsWriteLocks.delete(path);
});
return run;
}
/**
* Generates the hooks section for .claude/settings.local.json
*
@@ -41,11 +66,18 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// Read Claude Code's stdin JSON and forward it as the data field.
// Falls back to empty object if stdin is unavailable or malformed.
// COD-54: present the per-instance hook secret so the bypass keeps working while
// a tunnel is running. The value is read from the secret file AT EXECUTION TIME
// (path via $CODEMAN_HOOK_SECRET_FILE, set in every managed session's env), so it
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` +
`2>/dev/null || true`;
@@ -95,29 +127,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
if (keysToRemove.length === 0) return;
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
if (!existsSync(settingsPath)) return;
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
const env = existing.env as Record<string, string> | undefined;
if (!env) return;
let changed = false;
for (const key of keysToRemove) {
if (key in env) {
delete env[key];
changed = true;
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
}
if (!changed) return;
existing.env = env;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
const env = existing.env as Record<string, string> | undefined;
if (!env) return;
let changed = false;
for (const key of keysToRemove) {
if (key in env) {
delete env[key];
changed = true;
}
}
if (!changed) return;
existing.env = env;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
/**
@@ -126,30 +160,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
const currentEnv = (existing.env as Record<string, string>) || {};
for (const [key, value] of Object.entries(envVars)) {
if (value) {
currentEnv[key] = value;
} else {
delete currentEnv[key];
await withSettingsLock(settingsPath, async () => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
}
existing.env = currentEnv;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
const currentEnv = (existing.env as Record<string, string>) || {};
for (const [key, value] of Object.entries(envVars)) {
if (value) {
currentEnv[key] = value;
} else {
delete currentEnv[key];
}
}
existing.env = currentEnv;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
/**
@@ -158,26 +193,27 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
await withSettingsLock(settingsPath, async () => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
if (model) {
existing.model = model;
} else {
delete existing.model;
}
if (model) {
existing.model = model;
} else {
delete existing.model;
}
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
/**
@@ -186,22 +222,125 @@ export async function updateCaseModel(casePath: string, model: string | null): P
*/
export async function writeHooksConfig(casePath: string): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
await withSettingsLock(settingsPath, async () => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
// If file is malformed or doesn't exist, start fresh
existing = {};
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
// If file is malformed or doesn't exist, start fresh
existing = {};
}
const hooksConfig = generateHooksConfig();
const merged = { ...existing, ...hooksConfig };
const hooksConfig = generateHooksConfig();
const merged = { ...existing, ...hooksConfig };
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
/**
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
* accepting its hook events.
*
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
* install. This refreshes the hooks block so those stale curls regain the header.
*
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
* them), when the hooks aren't ours, or when the secret is already present — so it never
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // malformed — leave it untouched (case-create owns the happy path)
}
const hooksJson = JSON.stringify(existing.hooks ?? null);
const isOurs = hooksJson.includes('/api/hook-event');
// The generated curl carries this header literal (see generateHooksConfig); its
// absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
if (!isOurs || hasSecret) return;
const merged = { ...existing, ...generateHooksConfig() };
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry';
/**
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
* (present in every managed session via tmux setenv), so the config is static.
*/
export function generateStatusLineCommand(): string {
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
// footer is never blank if Codeman is unreachable.
return (
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- 2>/dev/null || echo codeman`
);
}
/**
* Add or remove Codeman's plan-usage statusLine exporter in
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
* (command targets `/api/status-telemetry`), so a user's hand-authored
* statusLine is never removed OR overwritten — on both the enable and disable
* paths we bail out when an existing statusLine isn't ours. Callers gate on
* Claude mode. Merges, preserving all other keys (hooks, env, model).
*/
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
}
const current = existing.statusLine as { command?: unknown } | undefined;
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
if (enabled) {
const desired = generateStatusLineCommand();
if (isOurs && current?.command === desired) return; // already current — skip rewrite
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
} else {
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
delete existing.statusLine;
}
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
+51 -8
View File
@@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
import { watch, type FSWatcher } from 'chokidar';
import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { ImageDetectedEvent } from './types.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
@@ -20,7 +20,9 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
/** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500;
@@ -166,8 +168,8 @@ export class ImageWatcher extends EventEmitter {
}
const ext = extname(path).toLowerCase();
// Don't ignore directories (needed for watching to work)
// Ignore files that aren't images
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
// Ignore files that aren't previewable images/documents
return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
},
});
@@ -229,15 +231,16 @@ export class ImageWatcher extends EventEmitter {
/**
* Handle a new file being detected.
* Verifies it's an image and emits the detection event.
* Verifies it's a previewable image/document and emits the detection event.
*/
private handleNewFile(sessionId: string, filePath: string): void {
const ext = extname(filePath).toLowerCase();
// Double-check it's an image extension
if (!IMAGE_EXTENSIONS.has(ext)) {
// Double-check it's a supported extension
if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
return;
}
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
// Burst limit: skip if too many images detected for this session in a short window
const now = Date.now();
@@ -259,7 +262,11 @@ export class ImageWatcher extends EventEmitter {
// Debounce rapid file creation (e.g., multiple screenshots quickly)
this.fileDeb.schedule(filePath, () => {
this.fileToSession.delete(filePath);
this.emitImageDetected(sessionId, filePath);
if (isAttachment) {
this.emitAttachmentDetected(sessionId, filePath);
} else {
this.emitImageDetected(sessionId, filePath);
}
// Increment burst count on actual emission (not on detection)
const b = this.burstTrackers.get(sessionId);
if (b) b.count++;
@@ -294,6 +301,42 @@ export class ImageWatcher extends EventEmitter {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
/**
* Emit the attachment:detected event with file metadata.
*/
private emitAttachmentDetected(sessionId: string, filePath: string): void {
try {
const stat = statSync(filePath);
const fileName = basename(filePath);
const workingDir = this.sessionDirs.get(sessionId);
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const event: AttachmentDetectedEvent = {
sessionId,
filePath,
relativePath,
fileName,
extension,
attachmentType: this.getAttachmentType(extension),
timestamp: Date.now(),
size: stat.size,
};
this.emit('attachment:detected', event);
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
private getAttachmentType(extension: string): AttachmentDetectedType {
if (extension === 'png') return 'image';
if (extension === 'pdf') return 'pdf';
if (extension === 'docx') return 'document';
if (extension === 'pptx') return 'presentation';
return 'document';
}
}
// Export singleton instance for convenience
+15
View File
@@ -14,6 +14,7 @@ import type {
ClaudeMode,
SessionMode,
OpenCodeConfig,
CodexConfig,
EffortLevel,
} from './types.js';
@@ -62,6 +63,7 @@ export interface CreateSessionOptions {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -80,6 +82,7 @@ export interface RespawnPaneOptions {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
@@ -192,6 +195,12 @@ export interface TerminalMultiplexer extends EventEmitter {
*/
getAttachArgs(muxName: string): string[];
/** Pin a mux window so client attaches do not automatically dictate its size. */
setManualWindowSize?(muxName: string): boolean;
/** Explicitly resize a mux window after Codeman accepts a terminal resize. */
resizeWindow?(muxName: string, cols: number, rows: number): boolean;
// ========== Availability ==========
/** Check if the multiplexer binary is available on the system */
@@ -205,4 +214,10 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
/** Capture a pane's current tmux buffer with ANSI escape codes preserved. */
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
captureActivePaneBuffer?(muxName: string): string | null;
}
+1
View File
@@ -8,3 +8,4 @@
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
export { PLANNER_PROMPT } from './planner.js';
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
export { RALPH_STATUS_CONTRACT, buildRalphLoopPrompt, type RalphLoopPromptOptions } from './ralph.js';
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview Ralph Loop prompt construction
*
* Builds the full `@ralph_prompt.md` content written for a new Ralph loop
* session, including the RALPH_STATUS block contract. The contract travels
* with the loop prompt (not the generated CLAUDE.md) so every Ralph session
* emits parseable status blocks regardless of the project's CLAUDE.md.
*
* @module prompts/ralph
*/
/**
* Structured status-reporting contract appended to every Ralph loop prompt.
*
* `RalphStatusParser` (src/ralph-status-parser.ts) parses this block from
* session output — keep the field names and enum values in sync with its
* patterns.
*/
export const RALPH_STATUS_CONTRACT = `## Status Reporting
End EVERY response with exactly this block — Codeman parses it to track the loop:
\`\`\`
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: <number>
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line: what to do next>
---END_RALPH_STATUS---
\`\`\`
Rules:
- \`EXIT_SIGNAL: true\` only when ALL tasks are verifiably done — then also output the completion phrase
- \`STATUS: BLOCKED\` when you need human input; describe the blocker in RECOMMENDATION
- Never set \`EXIT_SIGNAL: true\` while tests are failing
`;
export interface RalphLoopPromptOptions {
/** The user's task description (becomes the prompt header) */
taskDescription: string;
/** Completion phrase the session must emit inside <promise></promise> */
completionPhrase: string;
/** Whether a @fix_plan.md task plan was generated for this loop */
hasPlan: boolean;
}
/**
* Builds the full Ralph loop prompt written to `@ralph_prompt.md`.
*/
export function buildRalphLoopPrompt({ taskDescription, completionPhrase, hasPlan }: RalphLoopPromptOptions): string {
let fullPrompt = taskDescription + '\n\n---\n\n';
if (hasPlan) {
fullPrompt += '## Task Plan\n\n';
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
fullPrompt += '- Reference the plan at the start of each iteration\n';
fullPrompt += '- Update task checkboxes as you complete items\n';
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
}
fullPrompt += '## Iteration Protocol\n\n';
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
fullPrompt += '1. Check what work has already been done\n';
fullPrompt += '2. Make incremental progress toward completion\n';
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
fullPrompt += '## Verification\n\n';
fullPrompt += 'After each significant change:\n';
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
fullPrompt += '- Check for type/lint errors if applicable\n';
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
fullPrompt += '## Completion Criteria\n\n';
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
fullPrompt += '- All requirements from the task description are implemented\n';
fullPrompt += '- All tests pass\n';
fullPrompt += '- Changes are committed\n\n';
fullPrompt += '## If Stuck\n\n';
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
fullPrompt += "1. Document what you've tried\n";
fullPrompt += '2. Identify the specific blocker\n';
fullPrompt += '3. Try an alternative approach\n';
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n\n';
fullPrompt += RALPH_STATUS_CONTRACT;
return fullPrompt;
}
+13
View File
@@ -2779,6 +2779,19 @@ export class RespawnController extends EventEmitter {
return;
}
// Usage-limit pause: Claude can't work and the cycle's /clear would wipe
// the paused conversation — the auto-resume scheduler owns recovery here.
if (this.session.isLimitPaused) {
this.log('Skipping respawn cycle - usage-limit pause active (auto-resume armed)');
this.logAction('health', 'Respawn skipped: usage-limit pause (auto-resume armed)');
this.emit('respawnBlocked', {
reason: 'usage_limit',
details: 'Usage limit reached — waiting for scheduled auto-resume',
});
this.setState('watching');
return;
}
// Start the respawn cycle
this.cycleCount++;
this.log(`Starting respawn cycle #${this.cycleCount}`);
+92
View File
@@ -0,0 +1,92 @@
import { createHash } from 'node:crypto';
import { basename, extname } from 'node:path';
import type { SessionAttachmentHistoryItem } from './types/session.js';
import type { AttachmentDetectedEvent } from './types/tools.js';
import { getAttachmentType } from './attachment-registry.js';
export const ATTACHMENT_HISTORY_LIMIT = 100;
export interface ExternalAttachmentHistoryInput {
sessionId: string;
externalPath: string;
fileName?: string;
extension?: string;
size: number;
mtimeMs?: number;
timestamp?: number;
}
export function normalizeAttachmentExtension(extensionOrPath: string): string {
const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath;
return value.toLowerCase().replace(/^\./, '');
}
function historyKey(item: SessionAttachmentHistoryItem): string {
if (item.source === 'external' && item.externalPath) {
return `external:${item.externalPath}`;
}
return `detected:${item.relativePath || item.fileName}`;
}
function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string {
const source = item.externalPath || item.id || item.fileName;
const digest = createHash('sha256').update(source).digest('hex').slice(0, 16);
return `external:${digest}:${item.fileName}`;
}
export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem {
const { externalPath: _externalPath, ...safe } = item;
return {
...safe,
id: item.source === 'external' ? safeExternalHistoryId(item) : item.id,
};
}
export function sanitizeAttachmentHistory(
history: readonly SessionAttachmentHistoryItem[]
): SessionAttachmentHistoryItem[] {
return history.map(sanitizeAttachmentHistoryItem);
}
export function upsertAttachmentHistory(
history: readonly SessionAttachmentHistoryItem[],
item: SessionAttachmentHistoryItem
): SessionAttachmentHistoryItem[] {
const nextKey = historyKey(item);
return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT);
}
export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem {
return {
id: `detected:${event.relativePath || event.fileName}`,
sessionId: event.sessionId,
fileName: event.fileName,
extension: normalizeAttachmentExtension(event.extension),
attachmentType: event.attachmentType,
size: event.size,
mtimeMs: 0,
timestamp: event.timestamp,
source: 'detected',
relativePath: event.relativePath,
};
}
export function buildExternalAttachmentHistoryItem(
input: ExternalAttachmentHistoryInput
): SessionAttachmentHistoryItem {
const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath);
return {
id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${
input.fileName || basename(input.externalPath)
}`,
sessionId: input.sessionId,
fileName: input.fileName || basename(input.externalPath),
extension,
attachmentType: getAttachmentType(extension),
size: input.size,
mtimeMs: input.mtimeMs ?? 0,
timestamp: input.timestamp ?? Date.now(),
source: 'external',
externalPath: input.externalPath,
};
}
+184 -1
View File
@@ -1,15 +1,25 @@
/**
* @fileoverview Auto-compact and auto-clear automation for Session.
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
*
* Monitors token counts and triggers /compact or /clear commands when
* configurable thresholds are reached. Waits for Claude to be idle
* before sending commands, with retry logic and mutual exclusion
* (compact and clear never run simultaneously).
*
* Also implements auto-resume on usage limit ("token pause" control):
* when enabled and Claude stops on a usage-limit message ("5-hour limit
* reached ∙ resets 8pm" and friends — see usage-limit-patterns.ts), a timer
* is armed for the parsed reset time plus a safety buffer, then Escape
* (dismisses the rate-limit options dialog if open) and a "continue" prompt
* are sent so work resumes automatically. If the session is still limited,
* the fresh limit message re-arms the scheduler — that retry loop is the
* safety net for clock skew and parse imprecision.
*
* @module session-auto-ops
*/
import { EventEmitter } from 'node:events';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
// ============================================================================
// Timing Constants
@@ -78,6 +88,28 @@ async function executeWhenIdle(
}
}
// ============================================================================
// Auto-resume (usage-limit pause) constants
// ============================================================================
/** Safety buffer after the stated reset time before resuming (2 minutes) */
const RESUME_BUFFER_MS = 2 * 60_000;
/** Minimum delay before an overdue resume fires (lets output settle) */
const RESUME_MIN_DELAY_MS = 5_000;
/** Retry interval when the reset time is stale/past (5 minutes) */
const RESUME_RETRY_MS = 5 * 60_000;
/** Re-detections scheduling within this window of the current schedule are ignored */
const RESUME_DEDUP_TOLERANCE_MS = 90_000;
/** Delay between Escape (dialog dismiss) and the resume prompt */
const RESUME_ESC_DELAY_MS = 600;
/** Prompt sent to resume work after the limit resets */
const RESUME_PROMPT = 'continue';
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
@@ -131,6 +163,16 @@ export class SessionAutoOps extends EventEmitter {
private _isClearing: boolean = false;
private _autoClearTimer: NodeJS.Timeout | null = null;
// Auto-resume (usage-limit pause) state
private _autoResumeEnabled: boolean = false;
private _autoResumeTimer: NodeJS.Timeout | null = null;
/** Esc→continue gap timer; detections must NOT cancel a resume in flight */
private _resumeFollowupTimer: NodeJS.Timeout | null = null;
/** When the scheduled resume fires (epoch ms), null when not armed */
private _autoResumeAt: number | null = null;
private _limitPaused: boolean = false;
private _resumeAttempts: number = 0;
private readonly callbacks: AutoOpsCallbacks;
constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) {
@@ -207,6 +249,145 @@ export class SessionAutoOps extends EventEmitter {
}
}
// ============================================================================
// Auto-resume (usage-limit pause) — getters/setters
// ============================================================================
get autoResumeEnabled(): boolean {
return this._autoResumeEnabled;
}
/** When the scheduled resume fires (epoch ms), or null when not armed. */
get autoResumeAt(): number | null {
return this._autoResumeAt;
}
/** True while the session is believed to be paused on a usage limit. */
get isLimitPaused(): boolean {
return this._limitPaused;
}
setAutoResume(enabled: boolean): void {
this._autoResumeEnabled = enabled;
if (!enabled) {
this._cancelAutoResume('disabled');
}
}
/**
* Restore auto-resume state after a Codeman restart. A persisted pending
* schedule is re-armed; an overdue one fires shortly after boot (the limit
* footer won't reprint on its own, so without this the pause would stall).
*/
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
this._autoResumeEnabled = enabled;
if (!enabled || !resumeAt) return;
const now = Date.now();
this._scheduleResume(Math.max(resumeAt, now + RESUME_MIN_DELAY_MS), resumeAt, 'restored');
}
// ============================================================================
// Auto-resume — detection and scheduling
// ============================================================================
/**
* Scan cleaned terminal output for a usage-limit pause message and (re)arm
* the resume schedule. Called from the session's throttled parser path.
*/
processCleanData(cleanData: string): void {
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
// A resume is in flight (Esc sent, continue pending): output from our own
// Escape can redraw the stale limit footer — don't let it re-arm and
// cancel the continue. Fresh evidence arrives after the prompt is sent.
if (this._resumeFollowupTimer) return;
const detection = detectUsageLimitPause(cleanData);
if (!detection) return;
const now = Date.now();
const overdue = detection.resetAt <= now;
const fireAt = overdue
? now + RESUME_RETRY_MS // stale reset time → gentle retry loop
: Math.max(detection.resetAt + RESUME_BUFFER_MS, now + RESUME_MIN_DELAY_MS);
if (this._autoResumeTimer && this._autoResumeAt !== null) {
// Already armed: the footer redraws constantly, so ignore re-detections
// that land on (or later than) the current schedule. Only an EARLIER
// parsed time replaces it — an overdue retry never preempts a real one.
if (overdue || fireAt >= this._autoResumeAt - RESUME_DEDUP_TOLERANCE_MS) return;
}
this._scheduleResume(fireAt, detection.resetAt, detection.matched);
}
/**
* Claude started working — the limit is lifted (or the user resumed
* manually), so any pending auto-resume is obsolete.
*/
notifyWorking(): void {
this._resumeAttempts = 0;
if (!this._limitPaused && !this._autoResumeTimer && !this._resumeFollowupTimer) return;
this._cancelAutoResume('working');
}
private _scheduleResume(fireAt: number, resetAt: number, matched: string): void {
if (this._autoResumeTimer) {
clearTimeout(this._autoResumeTimer);
this._autoResumeTimer = null;
}
this._limitPaused = true;
this._autoResumeAt = fireAt;
const delay = Math.max(fireAt - Date.now(), 0);
console.log(
`[SessionAutoOps ${this.callbacks.getSessionId()}] Usage-limit pause detected ("${matched.slice(0, 60)}"), auto-resume in ${Math.round(delay / 60000)}min`
);
this._autoResumeTimer = setTimeout(() => void this._fireResume(), delay);
this.emit('limitPauseScheduled', { resetAt, resumeAt: fireAt, matched });
}
private async _fireResume(): Promise<void> {
this._autoResumeTimer = null;
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
if (this.callbacks.isWorking()) {
// Session resumed on its own (or via the user) — nothing to do.
this._cancelAutoResume('working');
return;
}
this._resumeAttempts++;
const attempt = this._resumeAttempts;
this._limitPaused = false; // optimistic: a fresh limit message re-arms us
this._autoResumeAt = null;
// Escape first: dismisses the rate-limit options dialog if Claude opened
// one (harmless at an idle prompt), then the resume prompt after a beat.
await this.callbacks.writeCommand('\x1b');
this._resumeFollowupTimer = setTimeout(() => {
this._resumeFollowupTimer = null;
if (this.callbacks.isStopped()) return;
void this.callbacks.writeCommand(`${RESUME_PROMPT}\r`);
this.emit('limitResume', { attempt });
}, RESUME_ESC_DELAY_MS);
}
private _cancelAutoResume(reason: 'disabled' | 'working' | 'stopped'): void {
const wasArmed = this._autoResumeTimer !== null || this._resumeFollowupTimer !== null || this._limitPaused;
if (this._autoResumeTimer) {
clearTimeout(this._autoResumeTimer);
this._autoResumeTimer = null;
}
if (this._resumeFollowupTimer) {
clearTimeout(this._resumeFollowupTimer);
this._resumeFollowupTimer = null;
}
this._limitPaused = false;
this._autoResumeAt = null;
if (wasArmed && reason !== 'stopped') {
this.emit('limitResumeCancelled', { reason });
}
}
// ============================================================================
// Threshold checks
// ============================================================================
@@ -321,5 +502,7 @@ export class SessionAutoOps extends EventEmitter {
this._autoClearTimer = null;
}
this._isClearing = false;
this._cancelAutoResume('stopped');
}
}
+5
View File
@@ -11,6 +11,7 @@
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { dataPath } from './config/instance.js';
/**
* Build Claude CLI permission flags based on the configured mode.
@@ -113,6 +114,8 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
}
@@ -149,5 +152,7 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
}
+297 -8
View File
@@ -46,6 +46,7 @@ import {
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
@@ -77,11 +78,20 @@ import {
buildShellEnv,
} from './session-cli-builder.js';
import { SessionAutoOps } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js';
import { parseAttachmentMagicLinks } from './attachment-magic.js';
import {
sanitizeAttachmentHistory,
upsertAttachmentHistory as upsertAttachmentHistoryList,
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem } from './types/session.js';
export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop';
/** Line buffer flush interval (100ms) - forces processing of partial lines */
const LINE_BUFFER_FLUSH_INTERVAL = 100;
@@ -121,6 +131,26 @@ const CTRL_L_PATTERN = /\x0c/g;
/** Pattern to split by newlines (CR or LF) */
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex';
}
/**
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
* that we strip so the browser keeps everything in the main buffer with scrollback
* reachable (the strip runs on both the live stream and the buffer replay).
*
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
* positioning, so dropping the alt-screen switch is safe — content stays in the
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
* legitimately need the alt screen) and `opencode` (renders its own TUI that
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return mode === 'codex' || mode === 'claude';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
@@ -250,6 +280,10 @@ export class Session extends EventEmitter {
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
// strip (see _handleTerminalOutput / isAltScreenStripMode)
private _altScreenSeqCarry: string = '';
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -299,6 +333,10 @@ export class Session extends EventEmitter {
private _parentAgentId: string | null = null;
private _childAgentIds: string[] = [];
// Bounded dedup set for terminal attachment magic-links already requested.
private _attachmentMagicSeen = new Set<string>();
private _attachmentHistory: SessionAttachmentHistoryItem[] = [];
// Nice prioritying configuration
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
@@ -311,6 +349,8 @@ export class Session extends EventEmitter {
// OpenCode configuration (only for mode === 'opencode')
private _openCodeConfig: OpenCodeConfig | undefined;
// Codex configuration (only for mode === 'codex')
private _codexConfig: CodexConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -379,12 +419,16 @@ export class Session extends EventEmitter {
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Codex configuration (only for mode === 'codex') */
codexConfig?: CodexConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
}
) {
super();
@@ -432,6 +476,11 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig;
}
// Apply Codex configuration
if (config.codexConfig) {
this._codexConfig = config.codexConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -446,6 +495,9 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
this.restoreAttachmentHistory(config.attachmentHistory);
}
// Initialize task tracker and forward events (store handlers for cleanup)
this._taskTracker = new TaskTracker();
@@ -503,6 +555,9 @@ export class Session extends EventEmitter {
this._totalOutputTokens = 0;
this.emit('autoClear', data);
});
this._autoOps.on('limitPauseScheduled', (data) => this.emit('limitPauseScheduled', data));
this._autoOps.on('limitResume', (data) => this.emit('limitResume', data));
this._autoOps.on('limitResumeCancelled', (data) => this.emit('limitResumeCancelled', data));
}
get status(): SessionStatus {
@@ -694,6 +749,11 @@ export class Session extends EventEmitter {
return this._allowedTools;
}
/** Codex CLI configuration for this session. */
get codexConfig(): CodexConfig | undefined {
return this._codexConfig;
}
// Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts
/**
@@ -803,6 +863,39 @@ export class Session extends EventEmitter {
this._autoOps.setAutoCompact(enabled, threshold, prompt);
}
get autoResumeEnabled(): boolean {
return this._autoOps.autoResumeEnabled;
}
/** When the scheduled usage-limit auto-resume fires (epoch ms), or null. */
get autoResumeAt(): number | null {
return this._autoOps.autoResumeAt;
}
/** True while the session is paused on a Claude usage limit (auto-resume armed). */
get isLimitPaused(): boolean {
return this._autoOps.isLimitPaused;
}
setAutoResume(enabled: boolean): void {
this._autoOps.setAutoResume(enabled);
// Users typically enable this WHILE a session already sits paused — the
// limit footer won't reprint on its own, so scan the recent buffer once.
// Only a future reset time counts: stale scrollback must not arm a resume.
if (enabled && !isExternalCliMode(this.mode)) {
const tail = this._terminalBuffer.value.slice(-8192).replace(ANSI_ESCAPE_PATTERN_FULL, '');
const detection = detectUsageLimitPause(tail);
if (detection && detection.resetAt > Date.now()) {
this._autoOps.processCleanData(tail);
}
}
}
/** Restore auto-resume state (and a pending schedule) after Codeman restart. */
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
this._autoOps.restoreAutoResume(enabled, resumeAt);
}
get imageWatcherEnabled(): boolean {
return this._imageWatcherEnabled;
}
@@ -831,6 +924,30 @@ export class Session extends EventEmitter {
return this._status === 'idle' || this._status === 'busy';
}
get attachmentHistory(): SessionAttachmentHistoryItem[] {
return sanitizeAttachmentHistory(this._attachmentHistory);
}
upsertAttachmentHistory(item: SessionAttachmentHistoryItem): void {
this._attachmentHistory = upsertAttachmentHistoryList(this._attachmentHistory, item);
}
restoreAttachmentHistory(history: SessionAttachmentHistoryItem[] | undefined): void {
this._attachmentHistory = [];
for (const item of [...(history ?? [])].reverse()) {
// Guard against malformed/legacy on-disk entries (null, non-object, or
// missing required fields). historyKey() dereferences source/fileName, so
// a bad item would otherwise throw inside the constructor and abort the
// entire mux-recovery loop.
if (!item || typeof item !== 'object' || !item.source || !item.fileName) continue;
this.upsertAttachmentHistory(item);
}
}
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined {
return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined;
}
toState(): SessionState {
return {
id: this.id,
@@ -847,6 +964,8 @@ export class Session extends EventEmitter {
autoCompactEnabled: this._autoOps.autoCompactEnabled,
autoCompactThreshold: this._autoOps.autoCompactThreshold,
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
totalCost: this._totalCost,
inputTokens: this._totalInputTokens,
@@ -865,8 +984,10 @@ export class Session extends EventEmitter {
cliAccountType: this._cliAccountType || undefined,
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -1004,7 +1125,12 @@ export class Session extends EventEmitter {
}
// Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
// Prevent tmux from letting the newest browser attach dictate global window
// size; accepted Codeman resize events update it explicitly below.
mux.setManualWindowSize?.(this._muxSession!.muxName);
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
@@ -1024,6 +1150,66 @@ export class Session extends EventEmitter {
}
private _handleTerminalOutput(data: string): void {
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
// sessions on mobile and desktop until the dialog closes:
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
// scrollback) — \x1b[?...l switches back.
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
// the visible viewport — are left intact; the TUI repaints those rows.)
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
// xterm.js forwards wheel events to the CLI instead of scrolling the
// viewport, so the conversation is in scrollback but unreachable.
// (Focus events at ?1004 are left alone — codeman uses them for
// active-tab detection.)
// Strip them at the source so neither the persisted buffer nor the live
// SSE/WS stream carries them, keeping everything in the main buffer with
// scrollback intact. These are controlled TUIs whose cursor-positioned
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
// keep the alt screen for vim/less/htop.
if (isAltScreenStripMode(this.mode)) {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
// until the next buffer replay. Hold back an incomplete digit-only CSI
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
// prepend it to the next chunk; complete sequences are never held.
data = this._altScreenSeqCarry + data;
this._altScreenSeqCarry = '';
// eslint-disable-next-line no-control-regex
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
if (splitTail) {
this._altScreenSeqCarry = splitTail[0];
data = data.slice(0, -splitTail[0].length);
if (!data) return;
}
data = data
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
}
// Scan terminal output for `codeman://attach?path=...` magic links and emit
// an attachmentRequested event for each newly-seen absolute path. The web
// server turns these into registered attachment cards.
const attachmentPaths = parseAttachmentMagicLinks(data);
for (const attachmentPath of attachmentPaths) {
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
this._attachmentMagicSeen.add(attachmentPath);
if (this._attachmentMagicSeen.size > 200) {
const oldest = this._attachmentMagicSeen.values().next().value;
if (oldest) this._attachmentMagicSeen.delete(oldest);
}
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
}
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
@@ -1038,7 +1224,7 @@ export class Session extends EventEmitter {
this._resetBuffers();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
console.log(
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
@@ -1056,6 +1242,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1070,6 +1257,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1083,8 +1271,8 @@ export class Session extends EventEmitter {
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
if (!isRestored) {
if (this.mode === 'opencode') {
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
if (isExternalCliMode(this.mode)) {
// External CLIs use custom TUIs — no ❯ prompt to detect.
// Wait for TUI to stabilize (output stops changing), then mark ready.
// Don't clear the buffer — the TUI's initial render IS the useful content.
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
@@ -1139,6 +1327,10 @@ export class Session extends EventEmitter {
if (this.mode === 'opencode') {
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
}
// Codex sessions require tmux for OPENAI_API_KEY injection via setenv
if (this.mode === 'codex') {
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
@@ -1216,6 +1408,7 @@ export class Session extends EventEmitter {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
}
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
@@ -1298,9 +1491,9 @@ export class Session extends EventEmitter {
* PTY data chunk. Receives accumulated raw data to process in one batch.
*/
private _processExpensiveParsers(rawData: string): void {
// Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser,
// token parsing, and CLI info parsing all depend on Claude's output format.
if (this.mode === 'opencode') return;
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
if (isExternalCliMode(this.mode)) return;
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
let _cleanData: string | null = null;
@@ -1322,6 +1515,11 @@ export class Session extends EventEmitter {
this._bashToolParser.processCleanData(getCleanData());
}
// Usage-limit pause detection (auto-resume on usage limit)
if (this._autoOps.autoResumeEnabled) {
this._autoOps.processCleanData(getCleanData());
}
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
if (rawData.includes('token')) {
this.parseTokensFromStatusLine(getCleanData());
@@ -1350,6 +1548,7 @@ export class Session extends EventEmitter {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
}
@@ -1631,6 +1830,7 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._altScreenSeqCarry = '';
this._lastActivityAt = Date.now();
}
@@ -2045,18 +2245,101 @@ export class Session extends EventEmitter {
private _ptyCols = 120;
private _ptyRows = 40;
/**
* Live WebSocket connections that have announced a desktop viewport for this
* session. While at least one is registered, small-viewport (mobile/tablet)
* resizes are ignored so a phone glancing at the session can't reflow the
* PTY under an active desktop view. Claims are connection-scoped: ws-routes
* registers them on a desktop-typed resize and releases them on socket
* close, so a mobile-only session (no desktop connected) keeps full control
* of its own size — including narrowing below the spawn default.
*
* Deliberate tradeoff: claims are WS-only because only a socket has a
* liveness signal. A desktop degraded to the stateless HTTP resize fallback
* still applies its typed resizes but holds no claim, so a concurrent phone
* can reflow it. This is cooperative UX arbitration, not a security
* boundary — untyped (legacy/API) resizes bypass claims by design.
*/
private _desktopSizeClaims = new Set<symbol>();
/**
* A desktop sizing claim only blocks small-viewport resizes while the
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
* window). An abandoned-but-connected desktop tab (left open at home, screen
* locked) must not hold a phone's view hostage: without this, the phone
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
*/
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;
/** Last evidence of a live desktop user (claim registered / typed input). */
private _lastDesktopActivityAt = 0;
/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
private _lastDesktopDims: { cols: number; rows: number } | null = null;
/** True while a small viewport reflowed the pane past an idle desktop claim. */
private _mobileSizeOverride = false;
/** Register a live desktop sizing claim (see _desktopSizeClaims). */
claimDesktopSizing(token: symbol): void {
this._desktopSizeClaims.add(token);
this._lastDesktopActivityAt = Date.now();
}
/** Release a desktop sizing claim when its connection goes away. */
releaseDesktopSizing(token: symbol): void {
this._desktopSizeClaims.delete(token);
}
/**
* Record desktop user activity (typed input over a claim-holding socket).
* If a phone reflowed the pane while the desktop was idle, the desktop
* layout is restored — "whoever is actively using the session wins".
*/
noteDesktopActivity(): void {
this._lastDesktopActivityAt = Date.now();
if (this._mobileSizeOverride && this._lastDesktopDims) {
this._mobileSizeOverride = false;
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
}
}
/**
* Resizes the PTY terminal dimensions.
* Skips the resize if dimensions haven't changed to avoid triggering
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
*
* Arbitration: while a desktop connection holds a sizing claim AND has been
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
* desktop view. Once the desktop goes idle, a phone may take the pane (the
* desktop re-asserts its size on its next typed input via
* noteDesktopActivity). Without a desktop connected, small viewports
* control the PTY size freely.
*
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number): void {
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows };
this._lastDesktopActivityAt = Date.now();
this._mobileSizeOverride = false;
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
return;
}
this._mobileSizeOverride = true;
}
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
this._ptyCols = cols;
this._ptyRows = rows;
if (this._mux && this._muxSession) {
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
}
this.ptyProcess.resize(cols, rows);
}
}
@@ -2148,6 +2431,12 @@ export class Session extends EventEmitter {
this._clearAllTimers();
// Drop desktop sizing claims defensively. Sockets normally release their
// own claim on close, but a hung client's close event can lag the session
// teardown by up to a ping cycle — don't let a stale claim suppress
// mobile resizes if this Session object sees any further use.
this._desktopSizeClaims.clear();
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if mux kill times out)
if (this.rejectPromise && !this._promptResolved) {
+152 -5
View File
@@ -13,6 +13,8 @@
* - `SubagentEvents` — typed event map
*
* Watched patterns: `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl`
* plus the `agent-{id}.meta.json` discovery sidecar (2026-06 format) and nested
* workflow agents under `subagents/workflows/{workflowId}/agent-{id}.jsonl`.
* Parses JSONL entries: user/assistant messages, tool_use/tool_result blocks, progress events.
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
*
@@ -30,7 +32,7 @@ import { watch, existsSync, FSWatcher } from 'node:fs';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
import { join, basename, dirname } from 'node:path';
import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
@@ -1150,6 +1152,10 @@ export class SubagentWatcher extends EventEmitter {
try {
await statAsync(subagentDir);
await this.watchSubagentDir(subagentDir, project, session);
// Workflow agents nest one level deeper under subagents/workflows/{wf}/
// (each holds its own agent-{id}.jsonl/.meta.json) — the flat watcher
// above never sees them, so discover and watch each workflow dir too.
await this.watchWorkflowDirs(subagentDir, project, session);
} catch {
// subagent dir doesn't exist - skip
}
@@ -1179,8 +1185,12 @@ export class SubagentWatcher extends EventEmitter {
try {
const files = await readdir(dir);
for (const file of files) {
if (file.endsWith('.jsonl')) {
if (file.startsWith('agent-') && file.endsWith('.jsonl')) {
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
} else if (file.startsWith('agent-') && file.endsWith('.meta.json')) {
// Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI
// Task subagents and no longer always writes a per-agent `.jsonl` here.
await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true);
}
}
} catch {
@@ -1190,13 +1200,26 @@ export class SubagentWatcher extends EventEmitter {
// Single directory watcher handles both new files and file content changes
try {
const watcher = watch(dir, (_eventType, filename) => {
if (!filename?.endsWith('.jsonl')) return;
const filePath = join(dir, filename);
// Only agent-{id}.jsonl / agent-{id}.meta.json — ignore siblings like a
// workflow dir's journal.jsonl (would otherwise register a bogus "journal" agent).
const isAgent = !!filename && filename.startsWith('agent-');
const isJsonl = isAgent && filename.endsWith('.jsonl');
const isMeta = isAgent && filename.endsWith('.meta.json');
if (!isJsonl && !isMeta) return;
const filePath = join(dir, filename as string);
// Debounce 100ms to batch rapid writes
this.fileDeb.schedule(filePath, () => {
if (!existsSync(filePath)) return;
if (isMeta) {
// Meta sidecar — discovery only (not a transcript; never tail it).
if (!this.fileAgentContext.has(filePath)) {
this.registerAgentMeta(filePath, projectHash, sessionId).catch(() => {});
}
return;
}
if (this.fileAgentContext.has(filePath)) {
// Known file — handle content change
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
@@ -1225,6 +1248,40 @@ export class SubagentWatcher extends EventEmitter {
}
}
/**
* Discover and watch nested workflow agent directories.
*
* The Workflow tool runs its subagents under
* `subagents/workflows/{workflowId}/agent-{id}.jsonl` (+ `.meta.json`, alongside
* a `journal.jsonl` of orchestration events). The flat `subagents/` watcher does
* not recurse, and Node's `fs.watch({ recursive: true })` is unsupported on Linux,
* so each workflow dir gets its own watcher here. Idempotent via `knownSubagentDirs`
* and re-driven by the periodic scan, so newly created workflows are picked up
* within one scan cycle (~5s) — the same latency as a new session's `subagents/`.
*/
private async watchWorkflowDirs(subagentDir: string, projectHash: string, sessionId: string): Promise<void> {
const workflowsRoot = join(subagentDir, 'workflows');
let names: string[];
try {
names = await readdir(workflowsRoot);
} catch {
return; // no workflows for this session
}
for (const name of names) {
// Workflow ids are directories (e.g. `wf_<id>`); skip any stray files that
// share the root (a workflow id never carries a file extension).
if (name.endsWith('.jsonl') || name.endsWith('.json')) continue;
const wfDir = join(workflowsRoot, name);
try {
const st = await statAsync(wfDir);
if (!st.isDirectory()) continue;
await this.watchSubagentDir(wfDir, projectHash, sessionId);
} catch {
// workflow dir vanished mid-scan — skip
}
}
}
/**
* Handle a file content change for an already-registered agent file.
* Tails from last known position, updates info, retries description if missing.
@@ -1291,6 +1348,16 @@ export class SubagentWatcher extends EventEmitter {
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
// Meta→transcript upgrade: the agent may already be registered from its
// `.meta.json` sidecar (discovery-only — nothing to tail). Now that the real
// `.jsonl` transcript has appeared, re-point to it and drop the stale sidecar
// context, emitting `updated` below rather than a duplicate `discovered`.
const priorEntry = this.agentInfo.get(agentId);
const isMetaUpgrade = !!priorEntry && priorEntry.filePath.endsWith('.meta.json');
if (isMetaUpgrade && priorEntry) {
this.fileAgentContext.delete(priorEntry.filePath);
}
// Initial info - handle race condition where file may be deleted between discovery and stat
let fileStat;
try {
@@ -1341,7 +1408,7 @@ export class SubagentWatcher extends EventEmitter {
// Track file context for directory watcher change handling
this.fileAgentContext.set(filePath, { projectHash, sessionId });
this.agentInfo.set(agentId, info);
this.emit('subagent:discovered', info);
this.emit(isMetaUpgrade ? 'subagent:updated' : 'subagent:discovered', info);
// Read existing content
this.tailFile(filePath, agentId, sessionId, 0)
@@ -1356,6 +1423,86 @@ export class SubagentWatcher extends EventEmitter {
this.resetIdleTimer(agentId);
}
/**
* Register a subagent discovered via its `agent-{id}.meta.json` sidecar.
*
* As of the 2026-06 Claude Code format change, TUI Task subagents write the
* `agent-{id}.meta.json` sidecar (`{ agentType, description, toolUseId }`) at
* spawn, a beat *before* the `agent-{id}.jsonl` transcript appears in the same
* dir. The legacy `.jsonl`-only discovery saw nothing in that window ("0
* tracked"); this surfaces the agent from the sidecar immediately. The
* transcript then lands within ~1s at the standard path and grows incrementally
* (empirically verified — it is fully tailable, NOT a dead end), so:
* - if the `.jsonl` already exists, defer to registerAgentFile (richer); else
* - register meta-only now, and when the sibling `.jsonl` arrives the dir
* watcher routes it to registerAgentFile, which detects the prior meta-only
* entry and *upgrades* it in place (re-points filePath, starts tailing).
*
* Edge case: if the transcript never materializes (e.g. an agent that dies
* before writing one), the agent stays meta-only — no live feed, status ages
* out via the idle timer / stale cleanup.
*/
private async registerAgentMeta(
metaPath: string,
projectHash: string,
sessionId: string,
isInitialScan: boolean = false
): Promise<void> {
if (this.fileAgentContext.has(metaPath)) return;
const agentId = basename(metaPath).replace('agent-', '').replace('.meta.json', '');
if (this.agentInfo.has(agentId)) return;
// Prefer a real transcript if one was written alongside the sidecar.
const jsonlPath = join(dirname(metaPath), `agent-${agentId}.jsonl`);
if (existsSync(jsonlPath)) {
await this.registerAgentFile(jsonlPath, projectHash, sessionId, isInitialScan);
return;
}
let fileStat;
try {
fileStat = await statAsync(metaPath);
} catch {
return; // deleted between discovery and stat
}
if (isInitialScan && Date.now() - fileStat.mtime.getTime() > STARTUP_MAX_FILE_AGE_MS) {
return; // skip stale historical agents on startup
}
let description: string | undefined;
try {
const meta = JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: string; description?: string };
description = meta.description || meta.agentType;
} catch {
return; // unreadable / not yet fully written — a later watch event retries
}
if (this.isInternalAgent(description)) return;
const info: SubagentInfo = {
agentId,
sessionId,
projectHash,
filePath: metaPath,
startedAt: fileStat.birthtime.toISOString(),
lastActivityAt: fileStat.mtime.getTime(),
status: 'active',
toolCallCount: 0,
entryCount: 0,
fileSize: fileStat.size,
description,
};
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
const oldestId = this.findOldestInactiveAgent();
if (oldestId) this.removeAgent(oldestId);
}
this.fileAgentContext.set(metaPath, { projectHash, sessionId });
this.agentInfo.set(agentId, info);
this.emit('subagent:discovered', info);
this.resetIdleTimer(agentId);
}
/**
* Tail a file from a specific position
*/
+47 -450
View File
@@ -1,461 +1,58 @@
# CLAUDE.md - Project Configuration
# CLAUDE.md
## Setup
Copy these files to your new project:
- `CLAUDE.md` → project root
- `.claude/settings.json` → `.claude/settings.json`
<!--
Generated by Codeman on [DATE]. This file is loaded into context at the
start of every Claude Code session in this project.
Then update the Project Overview section below.
Keep it short (target: under 200 lines). For each line ask: "would removing
this cause Claude to make mistakes?" If not, cut it. Don't document what
Claude can infer from the code itself (file layout, standard conventions,
APIs) — bloat causes Claude to ignore the rules that matter.
---
HTML comments like this one are stripped before loading, so fill-in notes
cost no context. If this file grows too big, split into path-scoped rules
in .claude/rules/*.md or import other files with @path/to/file syntax.
-->
This file guides Claude Code when working in this repository.
## Project
## Project Overview
<!-- Update this section with project-specific details -->
- **Project Name**: [PROJECT_NAME]
- **Description**: [PROJECT_DESCRIPTION]
- **Tech Stack**: [TECHNOLOGIES_USED]
- **Last Updated**: [DATE]
---
## Commands
<!-- List the exact commands Claude can't guess — fill in as the project
takes shape, then delete this comment:
| Task | Command |
|------|---------|
| Dev server | `npm run dev` |
| Test (single file) | `npm test -- test/<file>.test.ts` |
| Lint | `npm run lint` |
| Build | `npm run build` |
-->
## Code Style
<!-- Only rules that differ from language/framework defaults, one line each:
- Use 2-space indentation
- ES modules only — never require()
-->
## Workflow
- Full permissions are granted: read, write, edit, and execute without asking.
- Commit after every meaningful change; never batch unrelated work.
- Use conventional commits (`feat:` `fix:` `docs:` `refactor:` `test:` `chore:`); the message says what changed and why.
- Run the tests and linter before declaring any task done.
- Keep README and docs in sync with code changes.
## Codeman Environment
This session is managed by **Codeman** and runs within a tmux session.
This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirms it).
**Important**: Check for `CODEMAN_MUX=1` environment variable to confirm.
- Do NOT attempt to kill your own tmux session
- The session persists across disconnects - your work is safe
- Token usage, costs, and background tasks are tracked externally
---
## Work Principles
### Autonomy
Full permissions granted. Act decisively without asking - read, write, edit, execute freely.
### Git Discipline
- **Commit after every meaningful change** - never batch unrelated work
- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
- Commit message = what changed + why (not how)
### Documentation
- Update README.md when adding features or changing setup
- Update this file's session log after work sessions
- Keep docs in sync with code changes
### Thinking
Extended thinking is enabled. Use deep reasoning for complex architectural decisions, difficult bugs, and multi-file changes.
### Task Tracking (TodoWrite)
**ALWAYS use TodoWrite** to track tasks. This is non-negotiable for anything beyond trivial single-step work.
**When to use TodoWrite:**
- Multi-step tasks (3+ steps)
- Bug fixes requiring investigation
- Feature implementations
- Any work where progress tracking helps
- When the user provides multiple requests
**How to use it:**
1. **Before starting**: Break down the work into discrete todos
2. **During work**: Mark each todo `in_progress` before starting, `completed` when done
3. **One at a time**: Only ONE todo should be `in_progress` at any moment
4. **Immediately**: Mark todos complete the moment they're done - don't batch
**Why this matters:**
- Gives the user visibility into your progress
- Prevents forgetting tasks mid-work
- Creates accountability checkpoints
- Makes complex work manageable
**Example workflow:**
```
User: "Add user authentication with JWT"
→ TodoWrite:
- [ ] Research existing auth patterns in codebase
- [ ] Implement JWT token generation
- [ ] Add login endpoint
- [ ] Add token validation middleware
- [ ] Add protected route example
- [ ] Write tests
→ Mark "Research existing auth patterns" as in_progress
→ Do the research
→ Mark as completed, mark next as in_progress
→ Continue until all done
```
**Anti-patterns to avoid:**
- Starting work without creating todos first
- Having multiple todos `in_progress` simultaneously
- Batching completions at the end
- Skipping TodoWrite for "simple" multi-step tasks
---
## When to Use Agents
**Explore agent**: Codebase investigation, finding files, understanding architecture
```
"Use explore agent to find all authentication-related code"
```
**Parallel agents**: Independent tasks that don't conflict
```
"Research auth, database, and API modules in parallel using separate agents"
```
**Background execution**: Long-running operations (tests, builds)
```
"Run the test suite in the background while I continue"
```
**Sequential chaining**: When second task depends on first
```
"Use code-reviewer to find issues, then use fixer to resolve them"
```
---
## Planning Mode (Automatic)
**Automatically enter planning mode** when ANY of these conditions apply:
- Multi-file changes (3+ files affected)
- Architectural decisions
- Unclear or evolving requirements
- Risk mitigation on core systems
- New feature implementation
- Refactoring existing functionality
**Do NOT ask** whether to enter planning mode - just enter it when conditions are met.
Planning mode flow: read-only exploration → create plan → get approval → execute.
**Skip planning mode** only for:
- Single-file bug fixes
- Typo corrections
- Simple config changes
- Tasks with explicit step-by-step instructions from user
---
## Ralph Wiggum Loop (Autonomous Work Mode)
Ralph loops enable persistent, autonomous work on large tasks. When active, you continue iterating until completion criteria are met or the loop is cancelled.
### Starting a Ralph Loop
- Start: `/ralph-loop:ralph-loop`
- Cancel: `/ralph-loop:cancel-ralph`
- Help: `/ralph-loop:help`
### Time-Aware Loops
When the user specifies a **minimum duration** (e.g., "optimize for 8 hours", "work on this for 2 hours"), the loop becomes time-aware:
**At loop start:**
```bash
# Record start time
date +%s > /tmp/ralph_start_time
echo "Loop started at $(date)"
```
**Check elapsed time periodically:**
```bash
START=$(cat /tmp/ralph_start_time)
NOW=$(date +%s)
ELAPSED_HOURS=$(echo "scale=2; ($NOW - $START) / 3600" | bc)
echo "Elapsed: $ELAPSED_HOURS hours"
```
**Time-aware behavior:**
1. Complete all primary tasks from the user's prompt
2. After primary tasks done, check elapsed time
3. If minimum duration NOT reached:
- **Do NOT output completion phrase**
- Self-generate additional related tasks
- Continue working until minimum time elapsed
4. Only output completion phrase when:
- ALL primary tasks complete AND
- Minimum duration reached (or exceeded)
**Self-generating additional tasks when time remains:**
- Code optimization (performance, readability, DRY)
- Test coverage improvements
- Edge case handling
- Error message improvements
- Documentation gaps
- Security hardening
- Accessibility improvements
- Code cleanup and dead code removal
- Dependency updates
- Type safety improvements
**Example time-aware prompt:**
```
"Optimize the API endpoints for the next 4 hours. Focus on performance first,
then code quality. Minimum runtime: 4 hours."
Completion phrase: <promise>TIME_COMPLETE</promise>
```
**Time-aware loop behavior:**
```
[Start loop, record timestamp]
[Complete primary optimization tasks - 2 hours elapsed]
[Check time: 2/4 hours - NOT done yet]
[Self-generate: "Add caching to database queries"]
[Self-generate: "Optimize N+1 queries"]
[Self-generate: "Add request batching"]
[Continue working... 4.5 hours elapsed]
[Check time: 4.5/4 hours - minimum reached]
[All tasks complete, tests pass]
<promise>TIME_COMPLETE</promise>
```
### How You Know You're in a Ralph Loop
The user started the loop with a prompt containing:
- Clear task requirements
- A **completion phrase** (e.g., `<promise>COMPLETE</promise>`)
- **Optional: minimum duration** (e.g., "for the next 4 hours")
- Iteration limits (handled by the system)
Your job: Keep working until ALL requirements are verifiably done AND minimum time reached (if specified), then output the exact completion phrase.
### Core Behaviors During Ralph Loop
**1. Work Incrementally**
- Complete one sub-task at a time
- Verify it works before moving to the next
- Don't try to do everything in one pass
**2. Commit Frequently**
- Commit after each meaningful completion
- Creates recovery points if something breaks
- Shows progress in git history
```
git add . && git commit -m "feat(auth): add token refresh endpoint"
```
**3. Self-Correct Relentlessly**
```
Loop:
1. Implement/fix
2. Run tests
3. If tests fail → read error, fix, go to 1
4. Run linter
5. If lint errors → fix, go to 1
6. Commit
7. Continue to next task
```
**4. Track Progress**
Update the session log in this file as you complete tasks:
```markdown
| Date | Tasks Completed | Files Changed | Notes |
|------|-----------------|---------------|-------|
| YYYY-MM-DD | Add auth endpoint | auth.ts, routes.ts | Tests passing |
```
**5. Use Git History When Stuck**
If something isn't working:
```bash
git log --oneline -10
git diff HEAD~1
```
See what you already tried. Don't repeat failed approaches.
**6. Completion Phrase = Contract**
Only output the completion phrase (e.g., `<promise>COMPLETE</promise>`) when:
- ALL requirements from the original prompt are done
- ALL tests pass
- ALL linting passes
- Changes are committed
**Never output the completion phrase early.** The loop only ends when you say it's done.
### What Makes Good Completion Criteria
The user should provide criteria that are:
- **Verifiable**: Tests pass, lint clean, build succeeds
- **Measurable**: "5 endpoints", "all files in src/", "zero errors"
- **Binary**: Done or not done, no ambiguity
If the original prompt has vague criteria, ask clarifying questions before starting heavy work.
### Self-Correction Pattern (Include in Your Work)
```
FOR EACH TASK:
1. Implement the change
2. Run tests (npm test, pytest, go test, cargo test, etc.)
- If fail → read error, fix, retry
3. Run linter (npm run lint, ruff, golangci-lint, etc.)
- If fail → fix, go to step 2
4. Verify manually if needed
5. Commit with descriptive message
6. Update session log
7. Move to next task
WHEN ALL TASKS DONE:
1. Run full test suite
2. Run full lint
3. Verify build succeeds
4. Review all changes: git diff main
5. Only then output completion phrase
```
### Example: How to Think During Ralph Loop
**Original prompt**: "Add CRUD endpoints for todos with validation"
**Your approach**:
```
Task breakdown:
- [ ] GET /todos (list)
- [ ] POST /todos (create with validation)
- [ ] GET /todos/:id (single)
- [ ] PUT /todos/:id (update with validation)
- [ ] DELETE /todos/:id
- [ ] Tests for all endpoints
Starting with GET /todos...
[implement]
[test - passes]
[commit: "feat(todos): add GET /todos endpoint"]
[update session log]
Moving to POST /todos...
[implement]
[test - fails: validation not working]
[fix validation]
[test - passes]
[commit: "feat(todos): add POST /todos with validation"]
[update session log]
...continue until all done...
Final verification:
[npm test - all pass]
[npm run lint - clean]
[npm run build - succeeds]
<promise>COMPLETE</promise>
```
### When to NOT Output Completion Phrase
- Tests are failing (even one)
- Lint errors exist
- Build is broken
- You skipped a requirement
- You're unsure if something works
- **Minimum duration not reached** (for time-aware loops)
Instead: Fix the issue, verify, then complete. For time-aware loops: generate more tasks and keep improving until minimum time elapsed.
### RALPH_STATUS Block (Required During Ralph Loop)
At the **END of every response** during a Ralph Loop, output this structured status block:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: <number>
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line summary of what to do next>
---END_RALPH_STATUS---
```
**Rules:**
- Output this block at the end of **every** response, no exceptions
- Set `EXIT_SIGNAL` to `true` ONLY when ALL tasks are verifiably done
- Set `STATUS` to `BLOCKED` when you need human intervention
- Do NOT continue with busy work when `EXIT_SIGNAL` should be `true`
- Do NOT forget the status block — it is required for loop tracking
### Testing Limits
- **LIMIT testing to ~20% of total effort** per loop
- PRIORITIZE: Implementation > Documentation > Tests
- Only write tests for NEW functionality
- Do NOT refactor existing tests unless broken
- Do NOT run tests repeatedly without implementing new features
### Exit Scenarios (When to Set EXIT_SIGNAL)
| Scenario | STATUS | EXIT_SIGNAL | Action |
|----------|--------|-------------|--------|
| All tasks completed, tests pass | COMPLETE | true | Output completion phrase |
| No work remaining, specs done | COMPLETE | true | Output completion phrase |
| Making normal progress | IN_PROGRESS | false | Continue to next task |
| Test-only loop (no implementation) | IN_PROGRESS | false | Warn and shift to implementation |
| Stuck on same error repeatedly | BLOCKED | false | Describe blocker, request help |
| Needs human decision/intervention | BLOCKED | false | Describe what's needed |
**Anti-patterns to avoid:**
- Setting `EXIT_SIGNAL: true` when tests are failing
- Continuing to work when all tasks are genuinely done (busy work)
- Running the same failing test repeatedly without changing approach
- Adding features not in the original specifications
- Refactoring working code instead of completing assigned tasks
---
## Code Standards
### Before Writing
- Read existing code in the area you're modifying
- Follow existing patterns and conventions
- Check for similar implementations to reference
### During Implementation
- Keep changes focused and minimal
- Don't over-engineer
- Write tests for new functionality
### After Implementation
- Run tests
- Update docs if needed
- Commit with descriptive message
---
## Hooks Awareness
This project may have hooks that auto-format code after writes or validate operations. If a tool call behaves unexpectedly, hooks are likely the cause. Continue working - they're intentional.
---
## Session Log
| Date | Tasks Completed | Files Changed | Notes |
|------|-----------------|---------------|-------|
| [DATE] | Project created | CLAUDE.md | Initial setup |
---
## Current Task Queue
### Active Ralph Loop
**Status**: Not Active
**Completion Phrase**: -
### Pending Tasks
- [ ] <!-- Add tasks here -->
---
## Implementation Plans
<!-- Document plans before major implementations -->
---
## Notes & Decisions
<!-- Track important decisions and context -->
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
- The session persists across disconnects — your work is safe.
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
+8 -9
View File
@@ -15,18 +15,17 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUNDLED_TEMPLATE_PATH = join(__dirname, 'case-template.md');
const MINIMAL_FALLBACK = `# CLAUDE.md - Project Configuration
const MINIMAL_FALLBACK = `# CLAUDE.md
<!-- Generated by Codeman on [DATE]. Add the commands, code style rules, and
workflow notes Claude can't infer from the code. Keep it short. -->
This file guides Claude Code when working in this repository.
## Project
## Project Overview
- **Project Name**: [PROJECT_NAME]
- **Description**: [PROJECT_DESCRIPTION]
- **Last Updated**: [DATE]
## Session Log
| Date | Tasks Completed | Files Changed | Notes |
|------|-----------------|---------------|-------|
| [DATE] | Project created | CLAUDE.md | Initial setup |
`;
/**
+488 -15
View File
@@ -39,10 +39,11 @@ import {
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -164,6 +165,285 @@ export function parsePaneList(output: string): Map<string, number> {
return result;
}
/**
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
* Prefers the active pane and falls back to the first valid pane.
*/
export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): string | null {
if (!isValidMuxName(muxName)) {
return null;
}
if (paneTarget === undefined || paneTarget === 'active') {
return muxName;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
return null;
}
return `${muxName}.${paneTarget}`;
}
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
*/
export function resolveActivePaneTarget(output: string): string | null {
for (const line of output.split('\n')) {
const sep = line.indexOf(':');
if (sep === -1) continue;
const paneId = line.slice(0, sep).trim();
const active = line.slice(sep + 1).trim();
if (paneId && active === '1') return paneId;
}
return null;
}
type GraphemeSegmenter = {
segment(input: string): Iterable<{ segment: string }>;
};
const GRAPHEME_SEGMENTER: GraphemeSegmenter | null = (() => {
try {
const Segmenter = (
Intl as typeof Intl & {
Segmenter?: new (locale?: string, options?: { granularity: 'grapheme' }) => GraphemeSegmenter;
}
).Segmenter;
return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null;
} catch {
return null;
}
})();
function findEscapeEnd(text: string, start: number): number {
const type = text[start + 1];
if (type === '[') {
for (let i = start + 2; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) return i;
}
return text.length - 1;
}
if (type === ']') {
for (let i = start + 2; i < text.length; i++) {
if (text.charCodeAt(i) === 0x07) return i;
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
}
return text.length - 1;
}
if (type === 'P' || type === '^' || type === '_' || type === 'X') {
for (let i = start + 2; i < text.length; i++) {
if (text.charCodeAt(i) === 0x07) return i;
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
}
return text.length - 1;
}
return Math.min(start + 1, text.length - 1);
}
function sanitizePaneLineStyles(line: string): string {
let result = '';
for (let i = 0; i < line.length; i++) {
if (line[i] !== '\x1b') {
result += line[i];
continue;
}
const end = findEscapeEnd(line, i);
const sequence = line.slice(i, end + 1);
if (isSgrSequence(sequence)) {
result += sequence;
}
i = end;
}
return result;
}
function isSgrSequence(sequence: string): boolean {
return (
sequence.length >= 3 &&
sequence.charCodeAt(0) === 27 &&
sequence[1] === '[' &&
sequence.endsWith('m') &&
/^[0-9;:]*$/.test(sequence.slice(2, -1))
);
}
function isZeroWidthCodePoint(codePoint: number): boolean {
return (
codePoint === 0x00ad ||
codePoint === 0x034f ||
codePoint === 0x061c ||
codePoint === 0x115f ||
codePoint === 0x1160 ||
codePoint === 0x17b4 ||
codePoint === 0x17b5 ||
codePoint === 0x180e ||
codePoint === 0x200b ||
codePoint === 0x200c ||
codePoint === 0x200d ||
codePoint === 0x2060 ||
codePoint === 0xfeff ||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
codePoint === 0x05bf ||
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
codePoint === 0x05c7 ||
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
(codePoint >= 0x064b && codePoint <= 0x065f) ||
codePoint === 0x0670 ||
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
codePoint === 0x0711 ||
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
(codePoint >= 0x081b && codePoint <= 0x0823) ||
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
(codePoint >= 0x08e3 && codePoint <= 0x0902) ||
(codePoint >= 0x093a && codePoint <= 0x093c) ||
codePoint === 0x094d ||
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
);
}
function isWideCodePoint(codePoint: number): boolean {
return (
codePoint >= 0x1100 &&
(codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
);
}
function nextGrapheme(text: string, start: number): { value: string; nextIndex: number } {
if (GRAPHEME_SEGMENTER) {
const iterator = GRAPHEME_SEGMENTER.segment(text.slice(start))[Symbol.iterator]();
const next = iterator.next();
if (!next.done && next.value.segment) {
return { value: next.value.segment, nextIndex: start + next.value.segment.length };
}
}
const first = text.codePointAt(start);
if (first === undefined) return { value: '', nextIndex: start + 1 };
let value = String.fromCodePoint(first);
let nextIndex = start + value.length;
while (nextIndex < text.length) {
const codePoint = text.codePointAt(nextIndex);
if (codePoint === undefined || !isZeroWidthCodePoint(codePoint)) break;
const mark = String.fromCodePoint(codePoint);
value += mark;
nextIndex += mark.length;
}
return { value, nextIndex };
}
function terminalCellWidth(grapheme: string): number {
let hasVisible = false;
let hasWide = false;
for (let i = 0; i < grapheme.length; i++) {
const codePoint = grapheme.codePointAt(i);
if (codePoint === undefined) continue;
if (codePoint > 0xffff) i++;
if (isZeroWidthCodePoint(codePoint) || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
continue;
}
hasVisible = true;
if (isWideCodePoint(codePoint)) hasWide = true;
}
if (!hasVisible) return 0;
return hasWide ? 2 : 1;
}
function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): string {
let result = '';
let visibleColumns = 0;
let sawSgr = false;
for (let i = 0; i < line.length; i++) {
if (line[i] === '\x1b') {
const end = findEscapeEnd(line, i);
const sequence = line.slice(i, end + 1);
if (isSgrSequence(sequence)) {
result += sequence;
sawSgr = true;
}
i = end;
continue;
}
const grapheme = nextGrapheme(line, i);
const width = terminalCellWidth(grapheme.value);
if (width === 0) {
result += grapheme.value;
} else if (visibleColumns + width <= maxColumns) {
result += grapheme.value;
visibleColumns += width;
} else {
break;
}
i = grapheme.nextIndex - 1;
if (visibleColumns >= maxColumns) {
continue;
}
}
if (sawSgr) {
result += '\x1b[0m';
}
return result;
}
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
): string {
const cols = Math.max(1, geometry.cols);
// Paint the full pane width. Earlier this dropped the rightmost column
// (cols - 1) out of caution about last-column autowrap, but every painted
// row is immediately followed by an absolute cursor-position CSI (the next
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
// pending-wrap state before any further glyph — so the last column is safe.
const paintCols = cols;
const rows = Math.max(1, geometry.rows);
const parts: string[] = [];
for (let row = 0; row < Math.min(lines.length, rows); row++) {
const safeLine = truncatePaneLineByVisibleColumns(sanitizePaneLineStyles(lines[row]), paintCols);
parts.push(`\x1b[${row + 1};1H${safeLine}`);
}
const cursorX = Math.max(0, Math.min(cols - 1, geometry.cursorX));
const cursorY = Math.max(0, Math.min(rows - 1, geometry.cursorY));
parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`);
return parts.join('');
}
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
@@ -175,6 +455,10 @@ function isValidMuxName(name: string): boolean {
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
}
function isValidTerminalDimension(value: number): boolean {
return Number.isSafeInteger(value) && value > 0 && value <= 1000;
}
/**
* Validates that a path contains only safe characters.
* Prevents command injection via malformed paths.
@@ -261,6 +545,32 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
return parts.join(' ');
}
/**
* Build the codex CLI command with appropriate flags.
*
* Codeman launches Codex's native TUI and handles replay/scrollback by
* stripping destructive terminal sequences before xterm.js sees them.
*/
export function buildCodexCommand(config?: CodexConfig): string {
const parts = ['codex'];
if (config?.dangerouslyBypassApprovals) {
parts.push('--dangerously-bypass-approvals-and-sandbox');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeSessionId) {
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeId) parts.push('resume', safeId);
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -286,6 +596,7 @@ function buildSpawnCommand(options: {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
resumeSessionId?: string;
effort?: EffortLevel;
}): string {
@@ -310,6 +621,9 @@ function buildSpawnCommand(options: {
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
}
if (options.mode === 'codex') {
return buildCodexCommand(options.codexConfig);
}
return '$SHELL';
}
@@ -337,6 +651,29 @@ function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
}
}
/**
* Set sensitive environment variables for Codex on a tmux session via setenv.
* Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys.
*/
function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'];
for (const key of sensitiveVars) {
const val = process.env[key];
if (val) {
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — key may not be needed */
}
}
}
}
/**
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
@@ -519,11 +856,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
...(mode === 'codex' ? ['unset NO_COLOR'] : []),
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
// Path only (not the secret value): hook curl commands cat the file at
// execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
@@ -585,6 +926,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveOpenCodeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'codex') {
const dir = resolveCodexDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -599,6 +944,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
}
/**
* Configure Codex-specific environment on a tmux session.
* Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't
* appear in the bash command line.
*/
private _configureCodex(muxName: string): void {
setCodexEnvVars(this.tmux(), muxName);
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -614,6 +968,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
envOverrides,
effort,
@@ -662,6 +1017,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
effort,
});
@@ -682,14 +1038,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env };
delete cleanEnv.TMUX;
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
// blips do not poison tmux's long-lived getcwd state.
// Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
// pane cd's into workingDir below via respawn-pane.
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
cwd: TMUX_LAUNCH_CWD,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
env: cleanEnv,
});
this.resizeWindow(muxName, 120, 40);
// Set remain-on-exit now that the server is running — must be before respawn-pane
try {
@@ -705,6 +1064,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') {
this._configureOpenCode(muxName, openCodeConfig);
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
@@ -862,6 +1223,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
envOverrides,
effort,
@@ -884,6 +1246,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
resumeSessionId,
effort,
});
@@ -895,6 +1258,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') {
this._configureOpenCode(muxName, openCodeConfig);
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
@@ -920,6 +1285,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
private sessionExists(muxName: string): boolean {
if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false;
try {
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
@@ -1060,13 +1426,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Strategy 3: Kill tmux session by name
try {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// Session may already be dead
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
if (isValidMuxName(session.muxName)) {
try {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// Session may already be dead
}
}
// Strategy 4: Direct kill by PID as final fallback
@@ -1166,6 +1534,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
for (const [sessionName, pid] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
// Only admit names that pass the safe-name pattern. A foreign process on the
// shared `tmux -L codeman` socket could create a `codeman-…` session whose name
// contains shell metacharacters; rejecting it here keeps it out of this.sessions
// and away from the name-interpolating tmux call sites (M1).
if (!isValidMuxName(sessionName)) {
console.warn(`[TmuxManager] Skipping discovered tmux session with unsafe name: ${sessionName}`);
continue;
}
if (knownMuxNames.has(sessionName)) continue;
const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
@@ -1678,8 +2054,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Capture the current buffer of a specific pane.
* Returns the pane content with ANSI escape codes preserved.
* Capture the current visible text and SGR styles of a specific pane.
*
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
* codes are preserved, while cursor/erase/scroll-region controls are stripped
* before rows are repainted at absolute positions in browser xterm.
*/
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
if (IS_TEST_MODE) return '';
@@ -1695,16 +2074,67 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
}).replace(/\n+$/g, '');
try {
const cursor = execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
if (
Number.isFinite(cursorX) &&
Number.isFinite(cursorY) &&
Number.isFinite(cols) &&
Number.isFinite(rows) &&
cursorX >= 0 &&
cursorY >= 0 &&
cols > 0 &&
rows > 0
) {
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
}
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
}
return buffer;
} catch (err) {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
return null;
}
}
/**
* Capture the active pane for a tmux session.
*
* Pane ids are not stable across respawns or restores, so callers should not
* assume the first pane remains `%0`.
*/
captureActivePaneBuffer(muxName: string): string | null {
if (IS_TEST_MODE) return '';
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
return null;
}
try {
const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const target = resolveActivePaneTarget(output);
return target ? this.capturePaneBuffer(muxName, target) : null;
} catch (err) {
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
return null;
}
}
/**
* Start piping pane output to a file using tmux pipe-pane.
* Only pipes output direction (-O) to avoid echoing input.
@@ -1774,6 +2204,49 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
}
setManualWindowSize(muxName: string): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in setManualWindowSize:', muxName);
return false;
}
try {
execSync(`${this.tmux()} set-window-option -t ${shellescape(muxName)} window-size manual`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
return true;
} catch (err) {
console.error('[TmuxManager] Failed to set manual window size:', err);
return false;
}
}
resizeWindow(muxName: string, cols: number, rows: number): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in resizeWindow:', muxName);
return false;
}
if (!isValidTerminalDimension(cols) || !isValidTerminalDimension(rows)) {
console.error('[TmuxManager] Invalid resize dimensions:', { cols, rows });
return false;
}
// Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
// HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
// the Fastify event loop while other sessions' input/SSE are served. The sole
// caller (Session.resize) ignores the result, and under `window-size manual`
// the subsequent ptyProcess.resize is subordinate to this authoritative size.
exec(
`${this.tmux()} resize-window -t ${shellescape(muxName)} -x ${cols} -y ${rows}`,
{ timeout: EXEC_TIMEOUT_MS },
(err) => {
if (err) console.error('[TmuxManager] Failed to resize tmux window:', err);
}
);
return true;
}
isAvailable(): boolean {
return TmuxManager.isTmuxAvailable();
}
+34 -19
View File
@@ -25,12 +25,18 @@ export enum ApiErrorCode {
NOT_FOUND = 'NOT_FOUND',
/** Invalid input provided */
INVALID_INPUT = 'INVALID_INPUT',
/** Authentication required or failed */
UNAUTHORIZED = 'UNAUTHORIZED',
/** Session is currently busy */
SESSION_BUSY = 'SESSION_BUSY',
/** Operation failed */
OPERATION_FAILED = 'OPERATION_FAILED',
/** Request conflicts with current state (e.g. already running) */
CONFLICT = 'CONFLICT',
/** Resource already exists */
ALREADY_EXISTS = 'ALREADY_EXISTS',
/** Too many requests / rate limited */
RATE_LIMITED = 'RATE_LIMITED',
/** Operation could not be completed (well-formed but unprocessable) */
OPERATION_FAILED = 'OPERATION_FAILED',
/** Internal server error */
INTERNAL_ERROR = 'INTERNAL_ERROR',
}
@@ -41,12 +47,37 @@ export enum ApiErrorCode {
const ErrorMessages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found',
[ApiErrorCode.INVALID_INPUT]: 'Invalid input provided',
[ApiErrorCode.UNAUTHORIZED]: 'Authentication required',
[ApiErrorCode.SESSION_BUSY]: 'Session is currently busy',
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
[ApiErrorCode.CONFLICT]: 'Request conflicts with the current state',
[ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists',
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred',
};
/**
* Maps each API error code to its HTTP status. Single source of truth for the
* stable HTTP contract (see docs/api-reference.md). Applied centrally so every
* error response carries a conventional 4xx/5xx status, not 200.
*/
const ErrorStatus: Record<ApiErrorCode, number> = {
[ApiErrorCode.INVALID_INPUT]: 400,
[ApiErrorCode.UNAUTHORIZED]: 401,
[ApiErrorCode.NOT_FOUND]: 404,
[ApiErrorCode.SESSION_BUSY]: 409,
[ApiErrorCode.CONFLICT]: 409,
[ApiErrorCode.ALREADY_EXISTS]: 409,
[ApiErrorCode.OPERATION_FAILED]: 422,
[ApiErrorCode.RATE_LIMITED]: 429,
[ApiErrorCode.INTERNAL_ERROR]: 500,
};
/** HTTP status for an API error code (defaults to 400 for unknown codes). */
export function httpStatusForErrorCode(code: ApiErrorCode): number {
return ErrorStatus[code] ?? 400;
}
/**
* Hook event types triggered by Claude Code's hooks system
*/
@@ -82,22 +113,6 @@ export function createErrorResponse(code: ApiErrorCode, details?: string): ApiRe
};
}
/**
* Response for quick start operation
*/
export interface QuickStartResponse {
/** Whether the request succeeded */
success: boolean;
/** Created session ID */
sessionId?: string;
/** Path to case folder */
casePath?: string;
/** Case name */
caseName?: string;
/** Error message if failed */
error?: string;
}
/**
* Information about a case folder
*/
+1
View File
@@ -67,3 +67,4 @@ export * from './push.js';
export * from './plan.js';
export * from './orchestrator.js';
export * from './update.js';
export * from './workflow-run.js';
+60 -2
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -25,6 +25,7 @@
*/
import type { RespawnConfig } from './respawn.js';
import type { AttachmentDetectedType } from './tools.js';
/** Status of a Claude session */
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
@@ -38,7 +39,7 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode';
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
@@ -69,6 +70,21 @@ export interface OpenCodeConfig {
configContent?: string;
}
/** Codex (OpenAI CLI) browser rendering strategy. Hybrid TUI is the only supported mode. */
export type CodexRenderMode = 'hybrid';
/** Codex (OpenAI CLI) session configuration */
export interface CodexConfig {
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
model?: string;
/** Resume a previous codex conversation by session id (passed via --resume) */
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
dangerouslyBypassApprovals?: boolean;
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
renderMode?: CodexRenderMode;
}
/**
* Configuration for creating a new session
*/
@@ -86,6 +102,40 @@ export interface SessionConfig {
*/
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
export type SessionAttachmentHistorySource = 'detected' | 'external';
/**
* Session-scoped attachment history entry.
*
* `externalPath` is server-private. It may be present in the internal persisted
* history copy, but API-bound session state must sanitize it before returning
* to the browser.
*/
export interface SessionAttachmentHistoryItem {
/** Stable history identity used for dedupe and list rendering */
id: string;
/** Codeman session ID this item belongs to */
sessionId: string;
/** Display filename */
fileName: string;
/** Lowercase extension without a leading dot */
extension: string;
/** Viewer category used by the web UI */
attachmentType: AttachmentDetectedType;
/** File size in bytes */
size: number;
/** Last modified timestamp in milliseconds, if known */
mtimeMs: number;
/** Last time this attachment was seen or explicitly published */
timestamp: number;
/** How the attachment entered the session */
source: SessionAttachmentHistorySource;
/** Workspace-relative path for detected session files */
relativePath?: string;
/** Server-private absolute path for explicitly published external files */
externalPath?: string;
}
/**
* Current state of a session
*/
@@ -118,6 +168,10 @@ export interface SessionState {
autoCompactThreshold?: number;
/** Auto-compact prompt */
autoCompactPrompt?: string;
/** Auto-resume on usage limit enabled */
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/** Image watcher enabled for this session */
imageWatcherEnabled?: boolean;
/** Total cost in USD */
@@ -158,10 +212,14 @@ export interface SessionState {
cliLatestVersion?: string;
/** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Codex-specific configuration (only for mode === 'codex') */
codexConfig?: CodexConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Sanitized per-session attachment history. */
attachmentHistory?: SessionAttachmentHistoryItem[];
}
/**
+37 -1
View File
@@ -7,13 +7,14 @@
* - ActiveBashTool — a live bash command with extracted file paths and status
* - ActiveBashToolStatus — 'running' | 'completed'
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
* - AttachmentDetectedEvent — document/image file detection trigger for attachment cards
*
* Cross-domain relationships:
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
*
* Both types are in-memory only (not persisted). Broadcast via SSE events
* `subagent:tool_call` and `image:detected`. Parsed by BashToolParser
* `subagent:tool_call`, `image:detected`, and `attachment:detected`. Parsed by BashToolParser
* (`src/bash-tool-parser.ts`).
*/
@@ -61,3 +62,38 @@ export interface ImageDetectedEvent {
/** File size in bytes */
size: number;
}
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
/**
* Event emitted when a new previewable attachment file is detected in a session's
* working directory. Used to render a compact attachment card in the web UI.
*/
export interface AttachmentDetectedEvent {
/** Codeman session ID where the attachment was detected */
sessionId: string;
/** Full path to the detected attachment file */
filePath: string;
/** Path relative to the session's working directory (for file-raw/file-preview endpoints) */
relativePath: string;
/** Attachment file name (basename) */
fileName: string;
/** Lowercase extension without a leading dot */
extension: string;
/** Viewer category used by the web UI */
attachmentType: AttachmentDetectedType;
/** Timestamp when the attachment was detected */
timestamp: number;
/** File size in bytes */
size: number;
/** Registered attachment id for explicit live external attachments */
attachmentId?: string;
/** Source of the attachment card request */
source?: 'detected' | 'external';
/** Raw file route for explicit attachments */
rawUrl?: string;
/** Inline preview route for explicit attachments */
previewUrl?: string;
/** First-page thumbnail route for card previews */
thumbnailUrl?: string;
}
+6 -2
View File
@@ -13,8 +13,12 @@
* @module types/update
*/
/** Which init system supervises the running server (decides how we restart it). */
export type SupervisorKind = 'systemd' | 'launchd' | 'none';
/**
* Which init system supervises the running server (decides how we restart it).
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
* login): restart works by killing the server and letting launchd respawn it.
*/
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
/** How Codeman was installed — only `git` installs can self-update in place. */
export type InstallKind = 'git' | 'npm' | 'unknown';
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Types for ultracode / Workflow-tool run visualization.
*
* A Workflow run persists its state to
* `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_<runId>.json`
* (a sibling of the deeper `subagents/workflows/wf_<runId>/agent-*.jsonl`
* transcript tree that subagent-watcher tracks). This file is the single source
* for the master-detail "working agents" view: a run's tasks/phases on the LEFT
* and per-agent stats (tokens burned, tool calls) on the RIGHT.
*
* Field presence is STATE-DRIVEN and verified against real runs on disk:
* - state 'start' (queued): no agentId/tokens/toolCalls/startedAt/durationMs/...
* - state 'progress' (running): has agentId/tokens/toolCalls, no durationMs/resultPreview
* - state 'done' (finished): all fields, incl. durationMs/resultPreview
* Absent fields are genuinely ABSENT (never explicit null) — use `?:`, not null.
*
* @module types/workflow-run
*/
/** One declared phase of a run (from the run JSON's top-level `phases[]`, 0-indexed). */
export interface WorkflowRunPhase {
/** Phase title; equals each member agent's `phaseTitle`. Always present. */
title: string;
/** Human description of the phase. Always present in `phases[]`. */
detail: string;
}
/**
* One agent slot in a run, derived from `workflowProgress[]` entries where
* `type === 'workflow_agent'`. Optional fields are absent until the agent
* reaches the relevant lifecycle state (see module doc).
*/
export interface WorkflowAgentInfo {
/** 1-based stable slot index, unique within the run. Always present. */
index: number;
/** Agent label, e.g. "probe:dompurify-config". Always present. */
label: string;
/** 1-based phase number; join via `run.phases[phaseIndex - 1]`. Always present. */
phaseIndex: number;
/** Phase title (=== run.phases[phaseIndex-1].title). Always present. */
phaseTitle: string;
/** Model id, e.g. "claude-opus-4-8[1m]". Always present. */
model: string;
/** Lifecycle state. Real on-disk values: 'start' | 'progress' | 'done'. Open union. */
state: 'start' | 'progress' | 'done' | (string & {});
/** Epoch ms the slot was queued. Always present. */
queuedAt?: number;
/** Epoch ms of the last progress tick. Always present once any progress occurs. */
lastProgressAt?: number;
/** Truncated prompt the agent was given. Always present. */
promptPreview?: string;
/**
* Globally-unique agent id; equals the `agent-<agentId>.jsonl` transcript stem
* (the Phase-4 correlation key). ABSENT while state === 'start'.
*/
agentId?: string;
/** Epoch ms the agent began. Absent while 'start'. */
startedAt?: number;
/** Attempt counter. Absent while 'start'. */
attempt?: number;
/** Tokens burned so far (RIGHT pane). Absent while 'start'. */
tokens?: number;
/** Tool calls made so far (RIGHT pane). Absent while 'start'. */
toolCalls?: number;
/** Name of the most recent tool. Present for progress/done (occasionally absent). */
lastToolName?: string;
/** Short summary of the most recent tool call. May be absent even when 'done'. */
lastToolSummary?: string;
/** Total run time (ms). Present ONLY when 'done' — the live-vs-finished discriminator. */
durationMs?: number;
/** Truncated final result. Present ONLY when 'done'. */
resultPreview?: string;
}
/**
* Run-level info shipped to the browser.
*
* IMPORTANT: the on-disk JSON also carries `script` (15–660KB of embedded JS),
* `scriptPath`, `result`, and `logs`. The watcher STRIPS all four before the
* object is ever cached/broadcast — never let them reach SSE/getLightState/route.
*/
export interface WorkflowRunInfo {
/** Run id (=== the wf_<runId>.json filename stem). Always present. */
runId: string;
/** Workflow name from `meta.name`. Always present. */
workflowName?: string;
/**
* Run status. Real on-disk values seen: 'completed' | 'killed'.
* 'running' | 'failed' are inferred (parse defensively; keep open union).
*/
status?: 'completed' | 'killed' | 'running' | 'failed' | (string & {});
/** Concise human description (best LEFT-pane label). Always present. */
summary?: string;
/** Total agent slots, INCLUDING not-yet-started 'start' agents. */
agentCount?: number;
/** Total tokens across the run (partial mid-run). */
totalTokens?: number;
/** Total tool calls across the run (partial mid-run). */
totalToolCalls?: number;
/** Total run duration (ms). */
durationMs?: number;
/** Run start time (epoch MILLIS). */
startTime?: number;
/** ISO end/write timestamp. */
timestamp?: string;
/** Default model for the run. */
defaultModel?: string;
/** Background-task id that owns the run. */
taskId?: string;
/** Declared phases (0-indexed). */
phases: WorkflowRunPhase[];
/** Agents, derived from `workflowProgress` filtered to `type === 'workflow_agent'`. */
agents: WorkflowAgentInfo[];
/** Error message, present when status is 'killed'/'failed'. */
error?: string;
// ----- Watcher-derived (NOT in the JSON body — captured from the file path) -----
/** `<sessionUuid>` path segment (for per-session scoping). */
sessionUuid: string;
/** `<projHash>` path segment. */
projectHash: string;
/**
* Most recent activity (epoch ms): max agent `lastProgressAt`, else `startTime`.
* Drives recency filtering/sorting so finished long runs still surface.
*/
lastActivityAt: number;
}
/**
* Lightweight run projection (no `agents[]`) for the LEFT-pane list and the
* getLightState reconnect snapshot. A full run with 28 agents serializes to
* ~36KB; the snapshot ships dozens of runs, so it carries summaries only and the
* RIGHT pane fetches the full run (`GET /api/workflows/:runId`) on selection.
*/
export type WorkflowRunSummary = Omit<WorkflowRunInfo, 'agents'>;
+210
View File
@@ -0,0 +1,210 @@
/**
* @fileoverview Pure detection of Claude Code usage-limit pause messages.
*
* When a Claude subscription limit (5-hour rolling window, weekly, Opus weekly,
* or extra-usage balance) is hit, the Claude Code TUI stops working and prints a
* status line with the reset time. These helpers detect that state in cleaned
* (ANSI-stripped) terminal output and parse the reset time, so the session
* auto-resume feature (SessionAutoOps) can schedule a "continue" nudge.
*
* Message shapes covered (observed across Claude Code 1.0.x–2.1.x, 2025–2026):
* - `5-hour limit reached ∙ resets 8pm` (v1.0.109+ footer)
* - `Session limit reached ∙ resets 8pm`
* - `Weekly limit reached ∙ resets 6pm`
* - `Opus weekly limit reached ∙ resets Oct 6, 1pm`
* - `Limit reached · resets 1pm (America/Chicago) · /upgrade to Max…` (v2.0.55+)
* - `You've hit your limit · resets 1:40pm (America/New_York)` (v2.1.x)
* - `You've hit your weekly limit · resets Mon 12:00am`
* - `You've hit your limit · resets May 5 at 9pm (America/New_York)`
* - `You're out of extra usage · resets 1pm (America/Los_Angeles)`
* - `Claude usage limit reached. Your limit will reset at 2pm (America/New_York)` (v1.0.x inline)
* - `Claude AI usage limit reached|1755309600` (raw API, epoch seconds)
*
* Deliberately conservative: a limit phrase WITHOUT a parseable reset time is
* ignored (returns null) so ordinary conversation text mentioning "limit
* reached" can't arm the scheduler. The downstream retry loop (re-detection
* after each resume attempt) compensates for any parsing imprecision.
*
* All functions are pure (caller passes `now`) for testability.
*
* @module usage-limit-patterns
*/
/** Result of scanning terminal output for a usage-limit pause. */
export interface UsageLimitDetection {
/**
* Epoch ms when the limit resets. May be in the past when the matched
* message is stale (caller should treat past values as "retry soon").
*/
resetAt: number;
/** Matched message snippet (for logging and UI). */
matched: string;
}
/**
* Limit phrases that indicate Claude stopped on a usage limit.
* `\blimit reached` covers all "<X> limit reached" footer variants.
*/
const LIMIT_PHRASE_PATTERN =
/(?:\blimit\s+reached\b|you'?ve\s+hit\s+your\s+(?:\w+\s+)?limit\b|you'?re\s+out\s+of\s+extra\s+usage\b)/gi;
/**
* Reset-time spec following a limit phrase. Captures:
* 1 month (weekly resets >1 day out: "Oct 6, 1pm" / "May 5 at 9pm")
* 2 day-of-month
* 3 day-of-week ("Mon 12:00am")
* 4 hour (12h) 5 minutes 6 am/pm 7 IANA timezone in parens (optional)
* `resets?` + optional `at` also covers the v1.0.x "will reset at 2pm" form.
*/
const RESET_TIME_PATTERN =
/\bresets?\s+(?:at\s+)?(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:\s*,\s*|\s+at\s+)|(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*\(([^()\n]{1,64})\))?/i;
/** Raw API form: `Claude AI usage limit reached|1755309600` (epoch seconds). */
const EPOCH_LIMIT_PATTERN = /\busage\s+limit\s+reached\|(\d{9,11})\b/gi;
/** How far after a limit phrase the reset-time spec may appear (chars). */
const RESET_TIME_WINDOW = 160;
/** Parsed reset spec must not be further out than this (weekly max ≈ 7 days). */
const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Current UTC offset of an IANA timezone in ms, or null if unresolvable
* (e.g. the `(Etc/Unknown)` failure variant Claude Code can print).
* DST transitions inside the wait window can skew the result by an hour;
* the auto-resume retry loop absorbs that.
*/
function zoneOffsetMs(timeZone: string, at: number): number | null {
try {
const dtf = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' });
const name = dtf.formatToParts(at).find((p) => p.type === 'timeZoneName')?.value;
if (!name) return null;
const m = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(name);
if (!m) return null;
if (!m[1]) return 0; // plain "GMT"
const sign = m[1] === '-' ? -1 : 1;
return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0)) * 60_000;
} catch {
return null;
}
}
interface ResetSpec {
month?: number; // 0-11
dayOfMonth?: number; // 1-31
dayOfWeek?: number; // 0-6 (Sun-Sat)
hour: number; // 0-23
minute: number; // 0-59
timeZone?: string;
}
/**
* Compute the epoch ms for a parsed reset spec. Times are wall-clock in the
* given IANA timezone when present (and resolvable), otherwise server-local —
* Claude CLI runs on the same host as Codeman, so local time is the right
* default. Returns null when the spec is implausible (> ~8 days out).
*/
function resolveResetSpec(spec: ResetSpec, now: number): number | null {
const offset = spec.timeZone ? zoneOffsetMs(spec.timeZone, now) : null;
// Wall-clock view of "now": shifted-UTC when a zone offset is known,
// server-local otherwise. Read/build components with the matching API.
const useZone = offset !== null;
const wallNow = useZone ? new Date(now + offset) : new Date(now);
const get = {
year: () => (useZone ? wallNow.getUTCFullYear() : wallNow.getFullYear()),
month: () => (useZone ? wallNow.getUTCMonth() : wallNow.getMonth()),
date: () => (useZone ? wallNow.getUTCDate() : wallNow.getDate()),
day: () => (useZone ? wallNow.getUTCDay() : wallNow.getDay()),
};
const build = (y: number, mo: number, d: number): number => {
const wall = useZone
? Date.UTC(y, mo, d, spec.hour, spec.minute)
: new Date(y, mo, d, spec.hour, spec.minute).getTime();
return useZone ? wall - offset : wall;
};
let ts: number;
if (spec.month !== undefined && spec.dayOfMonth !== undefined) {
// Explicit date ("Oct 6, 1pm"). More than 2 days in the past → assume year
// rollover (message seen near New Year); slightly past → stale, keep as-is.
ts = build(get.year(), spec.month, spec.dayOfMonth);
if (ts < now - 2 * DAY_MS) {
ts = build(get.year() + 1, spec.month, spec.dayOfMonth);
}
} else if (spec.dayOfWeek !== undefined) {
// Day-of-week ("Mon 12:00am") → next occurrence.
const delta = (spec.dayOfWeek - get.day() + 7) % 7;
ts = build(get.year(), get.month(), get.date() + delta);
if (ts <= now) ts += 7 * DAY_MS;
} else {
// Time-only ("resets 8pm") → next occurrence within 24h.
ts = build(get.year(), get.month(), get.date());
if (ts <= now) ts += DAY_MS;
}
if (ts > now + MAX_RESET_HORIZON_MS) return null;
return ts;
}
/** Parse the reset-time spec found within `window`, or null. */
function parseResetTime(window: string, now: number): number | null {
const m = RESET_TIME_PATTERN.exec(window);
if (!m) return null;
const hour12 = parseInt(m[4], 10);
const minute = m[5] ? parseInt(m[5], 10) : 0;
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
const pm = m[6].toLowerCase() === 'pm';
const hour = (hour12 % 12) + (pm ? 12 : 0);
const spec: ResetSpec = { hour, minute };
if (m[1] && m[2]) {
spec.month = MONTHS.indexOf(m[1].toLowerCase());
spec.dayOfMonth = parseInt(m[2], 10);
if (spec.dayOfMonth < 1 || spec.dayOfMonth > 31) return null;
} else if (m[3]) {
spec.dayOfWeek = WEEKDAYS.indexOf(m[3].toLowerCase());
}
if (m[7]) spec.timeZone = m[7].trim();
return resolveResetSpec(spec, now);
}
/**
* Scan cleaned (ANSI-stripped) terminal output for a usage-limit pause message
* with a parseable reset time. Returns the LAST parseable occurrence in the
* chunk (most recent on screen), or null when none is found.
*/
export function detectUsageLimitPause(cleanData: string, now: number = Date.now()): UsageLimitDetection | null {
if (!cleanData || !/limit|extra usage/i.test(cleanData)) return null;
let result: UsageLimitDetection | null = null;
// Raw API epoch form
EPOCH_LIMIT_PATTERN.lastIndex = 0;
let em: RegExpExecArray | null;
while ((em = EPOCH_LIMIT_PATTERN.exec(cleanData)) !== null) {
const resetAt = parseInt(em[1], 10) * 1000;
if (resetAt > now + MAX_RESET_HORIZON_MS) continue;
result = { resetAt, matched: em[0] };
}
// TUI phrase + "resets <time>" forms
LIMIT_PHRASE_PATTERN.lastIndex = 0;
let pm: RegExpExecArray | null;
while ((pm = LIMIT_PHRASE_PATTERN.exec(cleanData)) !== null) {
const window = cleanData.slice(pm.index, pm.index + RESET_TIME_WINDOW);
const resetAt = parseResetTime(window, now);
if (resetAt !== null) {
result = { resetAt, matched: window.slice(0, 80).trim() };
}
}
return result;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
*
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
* object with the 5-hour rolling and 7-day weekly plan windows. The
* Codeman-managed statusLine exporter (see `hooks-config.generateStatusLineCommand`)
* POSTs that blob to `/api/status-telemetry`; these helpers normalize the subset
* Codeman displays and format the compact in-terminal footer string.
*
* Confirmed schema (empirically captured, CC 2.1.177, Claude Max — see
* `docs/usage-limits-display-plan.md`):
* rate_limits.{five_hour,seven_day}.{used_percentage: number 0-100,
* resets_at: number EPOCH-SECONDS}
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
* before the first API response and for non-subscriber auth — both yield null.
*
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
*
* @module usage-telemetry
*/
/** A single normalized plan-usage window. */
export interface UsageWindow {
/** Percent of the window consumed, 0–100. */
usedPercentage: number;
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
resetAt: number;
}
/** Normalized telemetry Codeman broadcasts to the UI. */
export interface StatusTelemetry {
fiveHour?: UsageWindow;
sevenDay?: UsageWindow;
/** Context-window percent used, 0–100 (bonus field from the same payload). */
contextUsedPercentage?: number;
/** Session cost in USD (bonus field). */
costUsd?: number;
/** Model display name, e.g. "Opus 4.8 (1M context)" (bonus field). */
modelDisplayName?: string;
}
/** Raw subset of the statusline stdin JSON (snake_case, as Claude emits it). */
export interface RawStatuslinePayload {
rate_limits?: {
five_hour?: { used_percentage?: number; resets_at?: number };
seven_day?: { used_percentage?: number; resets_at?: number };
};
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
cost?: { total_cost_usd?: number };
model?: { display_name?: string };
}
function clampPct(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function parseWindow(w?: { used_percentage?: number; resets_at?: number }): UsageWindow | undefined {
if (!w || typeof w.used_percentage !== 'number' || typeof w.resets_at !== 'number') return undefined;
if (!Number.isFinite(w.resets_at) || w.resets_at <= 0) return undefined;
return { usedPercentage: clampPct(w.used_percentage), resetAt: Math.round(w.resets_at * 1000) };
}
/**
* Normalize a raw statusline payload to the telemetry Codeman displays. Returns
* null when there is no plan-limit data to show (pre-first-response or a
* non-subscriber account) so the caller can skip broadcasting.
*/
export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): StatusTelemetry | null {
if (!data) return null;
const fiveHour = parseWindow(data.rate_limits?.five_hour);
const sevenDay = parseWindow(data.rate_limits?.seven_day);
if (!fiveHour && !sevenDay) return null;
const t: StatusTelemetry = {};
if (fiveHour) t.fiveHour = fiveHour;
if (sevenDay) t.sevenDay = sevenDay;
if (typeof data.context_window?.used_percentage === 'number') {
t.contextUsedPercentage = clampPct(data.context_window.used_percentage);
}
if (typeof data.cost?.total_cost_usd === 'number' && Number.isFinite(data.cost.total_cost_usd)) {
t.costUsd = data.cost.total_cost_usd;
}
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
t.modelDisplayName = data.model.display_name.slice(0, 60);
}
return t;
}
/**
* Current-session status for the in-terminal statusline footer. This is the
* "status of the current session" the user sees in Claude's footer — distinct
* from the account-wide plan limits, which live ONLY in the Codeman header chip.
*/
export interface SessionStatus {
modelDisplayName?: string;
inputTokens?: number;
outputTokens?: number;
contextUsedPercentage?: number;
}
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
function withCommas(n: number): string {
return Math.max(0, Math.round(n))
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** Extract current-session status (footer) from the raw payload. */
export function parseSessionStatus(data: RawStatuslinePayload | undefined): SessionStatus | null {
if (!data) return null;
const s: SessionStatus = {};
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
s.modelDisplayName = data.model.display_name.slice(0, 60);
}
const cw = data.context_window;
if (typeof cw?.total_input_tokens === 'number' && Number.isFinite(cw.total_input_tokens)) {
s.inputTokens = Math.max(0, cw.total_input_tokens);
}
if (typeof cw?.total_output_tokens === 'number' && Number.isFinite(cw.total_output_tokens)) {
s.outputTokens = Math.max(0, cw.total_output_tokens);
}
if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage);
}
return Object.keys(s).length ? s : null;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
* which live in the Codeman header chip. Claude requires a statusLine command to
* emit the rate_limits JSON at all, so this is what that command prints back.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
if (!s) return 'codeman';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
const tok: string[] = [];
if (s.inputTokens != null) tok.push(`in:${withCommas(s.inputTokens)}`);
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
return groups.length ? groups.join(' ') : 'codeman';
}
/**
* Stable signature for change-detection — the statusline fires on every
* assistant message, so the route only rebroadcasts when this value changes.
*
* Keys on EXACTLY the values the header chip displays: the two windows' ROUNDED
* percentages (the chip renders `Math.round`) + their reset times. Deliberately
* excludes contextUsedPercentage / costUsd / modelDisplayName — none are shown
* in the chip, and contextUsedPercentage in particular drifts on every assistant
* message, which would defeat the dedup and fan out a redundant SSE broadcast +
* localStorage write + identical chip re-render each time.
*/
export function telemetrySignature(t: StatusTelemetry): string {
return JSON.stringify([
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
t.fiveHour?.resetAt ?? null,
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
t.sevenDay?.resetAt ?? null,
]);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview Resolve the Codex (OpenAI) CLI binary across common install paths.
*
* Mirrors opencode-cli-resolver.ts pattern. Finds the `codex` binary
* and provides an augmented PATH string for tmux sessions.
*
* @module utils/codex-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
/** Common directories where the Codex CLI binary may be installed */
const CODEX_SEARCH_DIRS = [
join(homedir(), '.codex', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/** Cached directory containing the codex binary (empty string = searched but not found) */
let _codexDir: string | null = null;
/**
* Finds the directory containing the `codex` binary.
* Checks `which codex` first, then falls back to common install locations.
* Result is cached for subsequent calls.
*
* @returns Directory path, or null if not found
*/
export function resolveCodexDir(): string | null {
if (_codexDir !== null) return _codexDir || null;
// Try `which` first (respects current PATH)
try {
const result = execSync('which codex', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_codexDir = dirname(result);
return _codexDir;
}
} catch {
// Codex not in PATH, will check common locations
}
for (const dir of CODEX_SEARCH_DIRS) {
if (existsSync(join(dir, 'codex'))) {
_codexDir = dir;
return _codexDir;
}
}
_codexDir = ''; // mark as searched, not found
return null;
}
/**
* Check if Codex CLI is available on the system.
*/
export function isCodexAvailable(): boolean {
return resolveCodexDir() !== null;
}
+211
View File
@@ -0,0 +1,211 @@
/**
* @fileoverview Probe engine for `codeman doctor`. Resolves each registry tool
* against an injectable ProbeHost (real impl uses child_process/fs; tests inject
* fakes) and returns structured results. Pure given the host — no global I/O.
*
* @module utils/dependency-checker
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
export interface EnvDetectionInputs {
platform: NodeJS.Platform;
procVersion: string;
hasWindowsInterop: boolean;
}
export function detectEnvironment(inputs: EnvDetectionInputs): ProbeEnvironment {
if (inputs.platform === 'win32') return 'win32';
if (inputs.platform === 'darwin') return 'darwin';
const isWsl = /microsoft|wsl/i.test(inputs.procVersion) && inputs.hasWindowsInterop;
return isWsl ? 'wsl' : 'linux';
}
const DEFAULT_VERSION_RE = /(\d+\.\d+(?:\.\d+)?)/;
export function extractVersion(text: string, re?: RegExp): string | undefined {
const m = (re ?? DEFAULT_VERSION_RE).exec(text);
return m ? m[1] : undefined;
}
/** Returns -1 if a < b, 0 if equal, 1 if a > b (numeric, component-wise). */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d < 0 ? -1 : 1;
}
return 0;
}
export type ToolStatus = 'ok' | 'missing' | 'outdated' | 'skipped' | 'error';
export interface ToolResult {
id: string;
label: string;
category: ToolCategory;
required: boolean;
usedBy: string[];
status: ToolStatus;
version?: string;
path?: string;
installHint?: string;
reason?: string;
}
export interface ProbeHost {
environment: ProbeEnvironment;
which(bin: string): string | null;
fileExists(path: string): boolean;
runVersion(bin: string, args: string[]): string | null;
windowsProgramRoots(): string[];
windowsFileVersion(winPath: string): string | null;
}
function finalize(
base: Pick<ToolResult, 'id' | 'label' | 'category' | 'required' | 'usedBy'>,
tool: ToolDependency,
path: string,
version: string | undefined
): ToolResult {
if (tool.minVersion) {
if (!version) return { ...base, status: 'error', path, reason: 'version required but could not be parsed' };
if (compareVersions(version, tool.minVersion) < 0) return { ...base, status: 'outdated', path, version };
}
return { ...base, status: 'ok', path, version };
}
export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
const base = {
id: tool.id,
label: tool.label,
category: tool.category,
required: tool.required,
usedBy: tool.usedBy ?? [],
};
const installHint = tool.installHint?.[host.environment];
const spec = tool.resolvers.find((r) => r.match.includes(host.environment));
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
if (spec.resolver.kind === 'path') {
const { bins, versionArg, versionRegex } = spec.resolver;
for (const bin of bins) {
const resolved = host.which(bin);
if (resolved) {
const out = host.runVersion(bin, [versionArg ?? '--version']);
const version = out ? extractVersion(out, versionRegex) : undefined;
return finalize(base, tool, resolved, version);
}
}
return { ...base, status: 'missing', installHint };
}
// windows-side
const { appDirs, exes } = spec.resolver;
for (const root of host.windowsProgramRoots()) {
for (const dir of appDirs) {
for (const exe of exes) {
const winPath = `${root}/${dir}/${exe}`;
if (host.fileExists(winPath)) {
const raw = host.windowsFileVersion(winPath);
const version = raw ? extractVersion(raw) : undefined;
return finalize(base, tool, winPath, version);
}
}
}
}
return { ...base, status: 'missing', installHint };
}
export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResult[] {
return registry.map((tool) => checkTool(tool, host));
}
function safeWhich(bin: string): string | null {
try {
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const first = out.split(/\r?\n/)[0]?.trim();
return first && existsSync(first) ? first : null;
} catch {
return null;
}
}
function safeRunVersion(bin: string, args: string[]): string | null {
try {
return execFileSync(bin, args, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch (err: unknown) {
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
const stdout = (err as { stdout?: Buffer | string })?.stdout;
return stdout ? stdout.toString() : null;
}
}
function readProcVersion(): string {
try {
return readFileSync('/proc/version', 'utf-8');
} catch {
return '';
}
}
function listWindowsProgramRoots(): string[] {
const roots: string[] = [];
try {
for (const entry of readdirSync('/mnt')) {
for (const pf of ['Program Files', 'Program Files (x86)']) {
const root = `/mnt/${entry}/${pf}`;
if (existsSync(root)) roots.push(root);
}
}
} catch {
// /mnt absent (not WSL) -> no roots
}
return roots;
}
function readWindowsFileVersion(winPath: string): string | null {
try {
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const out = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
return out || null;
} catch {
return null;
}
}
export function createRealHost(): ProbeHost {
const environment = detectEnvironment({
platform: process.platform,
procVersion: readProcVersion(),
hasWindowsInterop: safeWhich('cmd.exe') !== null || safeWhich('powershell.exe') !== null,
});
return {
environment,
which: safeWhich,
fileExists: existsSync,
runVersion: safeRunVersion,
windowsProgramRoots: listWindowsProgramRoots,
windowsFileVersion: readWindowsFileVersion,
};
}
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Renders ToolResult[] from the dependency checker into a
* human-readable grouped table or JSON, and computes the process exit code.
* Plain text only (no color) so output is stable and snapshot-friendly; the
* CLI layer may colorize.
*
* @module utils/dependency-report
*/
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
import type { ToolResult, ToolStatus } from './dependency-checker.js';
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
function glyph(r: ToolResult): string {
if (r.status === 'ok') return '✓';
if (r.status === 'skipped') return '○';
return r.required ? '✗' : '○';
}
function statusText(r: ToolResult): string {
if (r.status === 'ok') return r.version ?? 'installed';
if (r.status === 'outdated') return `${r.version ?? '?'} (below minimum)`;
if (r.status === 'skipped') return 'n/a';
if (r.status === 'error') return 'version error';
return 'not found';
}
export function computeExitCode(results: ToolResult[]): number {
const failed = results.some(
(r) => r.required && (r.status === 'missing' || r.status === 'outdated' || r.status === 'error')
);
return failed ? 1 : 0;
}
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
for (const category of CATEGORY_ORDER) {
const rows = results.filter((r) => r.category === category);
if (rows.length === 0) continue;
lines.push(category.toUpperCase());
for (const r of rows) {
const detail = r.path ? ` ${r.path}` : '';
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
if (r.installHint) lines.push(` install: ${r.installHint}`);
}
lines.push('');
}
const ok = results.filter((r) => r.status === 'ok').length;
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
return lines.join('\n');
}
export interface DependencyReportJson {
platform: { environment: ProbeEnvironment };
summary: { ok: number; requiredMissing: number; optionalMissing: number; exitCode: number };
tools: ToolResult[];
}
export function renderJson(results: ToolResult[], environment: ProbeEnvironment): DependencyReportJson {
const byStatus = (s: ToolStatus) => results.filter((r) => r.status === s).length;
return {
platform: { environment },
summary: {
ok: byStatus('ok'),
requiredMissing: results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length,
optionalMissing: results.filter((r) => !r.required && r.status === 'missing').length,
exitCode: computeExitCode(results),
},
tools: results,
};
}
+2
View File
@@ -22,8 +22,10 @@ export {
execPattern,
} from './regex-patterns.js';
export { MAX_SESSION_TOKENS } from './token-validation.js';
export { isSafePushEndpoint } from './push-endpoint-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview SSRF guard for web-push subscription endpoints (security review M7).
*
* A push `endpoint` is an attacker-suppliable URL that the server fetches via
* `webpush.sendNotification`. On the no-auth loopback default a local page (or any
* non-browser client) could register an endpoint pointing at the cloud metadata
* service (169.254.169.254) or an internal host, turning the server into an SSRF
* proxy. We require https and reject IP-literal hosts in private/loopback/
* link-local/reserved ranges. DNS-named hosts are allowed (every real push service
* — FCM, Mozilla, Apple, WNS — uses a public DNS name); this is checked both at
* subscribe time (schema) and again at send time (defense-in-depth).
*
* Note: a hostname that *resolves* to an internal IP (DNS rebinding) is not caught
* here without async resolution; the realistic, documented vector (a direct
* internal IP literal) is closed.
*/
import { isIP } from 'node:net';
/** True if `host` is an IP literal in a private, loopback, link-local, or reserved range. */
function isPrivateOrReservedIp(host: string): boolean {
const kind = isIP(host);
if (kind === 0) return false; // not an IP literal — a DNS name
if (kind === 4) {
const [a, b] = host.split('.').map(Number);
if (a === 0 || a === 10 || a === 127) return true; // unspecified, private, loopback
if (a === 169 && b === 254) return true; // link-local (incl. 169.254.169.254 metadata)
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (RFC 6598)
if (a >= 224) return true; // multicast + reserved (224.0.0.0+)
return false;
}
// IPv6
const h = host.toLowerCase();
if (h === '::1' || h === '::') return true; // loopback, unspecified
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 unique-local
// IPv4-mapped (::ffff:a.b.c.d). URL/Node may normalize the dotted tail to hex
// (::ffff:7f00:1), so handle both forms and re-check the embedded IPv4.
const mappedDotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (mappedDotted) return isPrivateOrReservedIp(mappedDotted[1]);
const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mappedHex) {
const hi = parseInt(mappedHex[1], 16);
const lo = parseInt(mappedHex[2], 16);
return isPrivateOrReservedIp(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
}
return false;
}
/**
* Validate a web-push endpoint URL is safe to fetch server-side.
* Requires an https URL whose host is not an internal/reserved IP literal.
*/
export function isSafePushEndpoint(endpoint: string): boolean {
let url: URL;
try {
url = new URL(endpoint);
} catch {
return false;
}
if (url.protocol !== 'https:') return false;
if (!url.hostname) return false;
// URL.hostname wraps IPv6 literals in brackets ([::1]); strip them for isIP().
const host = url.hostname.replace(/^\[|\]$/g, '');
return !isPrivateOrReservedIp(host);
}
+63 -9
View File
@@ -19,6 +19,7 @@ import {
AUTH_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -28,12 +29,16 @@ interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
hookSecretFailures: StaleExpirationMap<string, number> | null;
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
*
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the
* shared hook secret unconditionally (COD-91) — see the onRequest hook below.
*
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
@@ -41,6 +46,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
authSessions: null,
authFailures: null,
qrAuthFailures: null,
hookSecretFailures: null,
};
const authPassword = process.env.CODEMAN_PASSWORD;
@@ -67,24 +73,67 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
refreshOnGet: false,
});
// Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
// legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
// their 401s against the shared bucket would 429 every cookie-less request from
// loopback — locking out the Basic-Auth login path (and, through a tunnel, every
// client, since tunneled traffic also arrives as 127.0.0.1).
state.hookSecretFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
const authSessions = state.authSessions;
const authFailures = state.authFailures;
const hookSecretFailures = state.hookSecretFailures;
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
function sendAuthRateLimit(
reply: FastifyReply,
clientIp: string,
failures: StaleExpirationMap<string, number> = authFailures
): void {
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
if (req.url === '/api/hook-event' && req.method === 'POST') {
// Hook events + statusline telemetry come from local Claude Code (curl from
// localhost) — no Basic-Auth credentials available. Validated downstream by
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
//
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. COD-91: require the shared hook secret on the loopback bypass
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
// always closes the gap without breaking the legitimate hook channel.
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
done();
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
// Always require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
// (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
return;
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return;
}
// Non-localhost hook requests fall through to normal auth
@@ -205,7 +254,12 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
const scriptSrc =
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
const connectSrc = "connect-src 'self' wss://api.deepgram.com";
const workerSrc = gesture ? "; worker-src 'self' blob:" : '';
// blob: workers are needed unconditionally: terminal-ui's _safeYield tick
// worker (throttling escape) is created from a Blob URL. Without this, every
// page load logs a CSP violation and the worker leg of _safeYield is dead.
// Risk is minimal — only same-origin scripts (already governed by script-src)
// can construct blob workers.
const workerSrc = "; worker-src 'self' blob:";
const csp =
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
+10
View File
@@ -6,6 +6,16 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
+22
View File
@@ -0,0 +1,22 @@
/**
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
*
* The status-telemetry route writes the latest broadcast value here; the SSE
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
* chip shows immediately on a fresh page load / SSE reconnect — before any new
* statusline render arrives, and without relying on per-browser localStorage.
*
* Null until the first telemetry of the process; cleared naturally on restart.
*
* @module plan-usage-latest
*/
let latest: Record<string, unknown> | null = null;
export function setLatestPlanUsage(value: Record<string, unknown>): void {
latest = value;
}
export function getLatestPlanUsage(): Record<string, unknown> | null {
return latest;
}
+10 -1
View File
@@ -44,11 +44,20 @@ Object.assign(CodemanApp.prototype, {
async _apiJson(path, opts = {}) {
const res = await this._api(path, opts);
if (!res || !res.ok) return null;
let body;
try {
return await res.json();
body = await res.json();
} catch {
return null;
}
// Uniform API envelope (stable HTTP contract): unwrap { success:true, data } → data;
// { success:false } → null (errors also surface as a non-ok HTTP status above).
// Legacy/bare bodies pass through unchanged.
if (body && typeof body === 'object') {
if (body.success === false) return null;
if (body.success === true && 'data' in body) return body.data;
}
return body;
},
/**
+650 -73
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -51,12 +51,14 @@ const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
// Z-index base values for layered floating windows
const ZINDEX_SUBAGENT_BASE = 1000;
@@ -112,9 +114,24 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when
// they overflow one row (and the user hasn't pinned the manual two-row layout).
function shouldAutoWrapTabs(input) {
if (!input || input.deviceType !== 'desktop') return false;
if (input.manualTwoRows) return false;
if ((input.tabCount || 0) < 2) return false;
const scrollWidth = Number(input.scrollWidth) || 0;
const clientWidth = Number(input.clientWidth) || 0;
return scrollWidth > clientWidth + 1;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
@@ -242,10 +259,14 @@ const SSE_EVENTS = {
SESSION_WORKING: 'session:working',
SESSION_AUTO_CLEAR: 'session:autoClear',
SESSION_AUTO_COMPACT: 'session:autoCompact',
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
SESSION_LIMIT_RESUME: 'session:limitResume',
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
@@ -329,8 +350,14 @@ const SSE_EVENTS = {
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
SUBAGENT_COMPLETED: 'subagent:completed',
// Workflow runs (ultracode / Workflow tool)
WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered',
WORKFLOW_RUN_UPDATED: 'workflow:run_updated',
WORKFLOW_RUN_REMOVED: 'workflow:run_removed',
// Images
IMAGE_DETECTED: 'image:detected',
ATTACHMENT_DETECTED: 'attachment:detected',
// Tunnel
TUNNEL_STARTED: 'tunnel:started',
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -149,7 +149,7 @@ Object.assign(CodemanApp.prototype, {
}
const data = await resp.json();
return data.path;
return data.data.path;
},
// Decode an image File through the browser and re-encode it to a format the
+171 -51
View File
@@ -8,7 +8,7 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
<meta name="theme-color" content="#11151c">
<meta name="google" content="notranslate">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -30,21 +30,31 @@
'defer' preserves execution order (xterm loads before fit addon). -->
<script defer src="vendor/xterm.min.js"></script>
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<!-- SerializeAddon: snapshots xterm state (viewport + scrollback + attrs) for
per-session restore on tab switches. Lets codex tabs survive switch-away
without codeman having to replay codex's byte stream (which loses earlier
conversation because codex's TUI redraws drop it from the viewport). -->
<script defer src="vendor/xterm-addon-serialize.min.js"></script>
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>
<script defer src="vendor/marked.min.js"></script>
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
<script defer src="vendor/dompurify.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#0d0d0d}
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
.skeleton-terminal{flex:1;background:#161b23}
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
.app-loaded .loading-skeleton{display:none}
</style>
</head>
@@ -76,7 +86,7 @@
<!-- Detached single-session window title (shown only in solo mode) -->
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
<div class="header-right">
<div class="header-right" id="headerRight">
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
@@ -106,8 +116,14 @@
<span class="stat-value" id="statMem">--</span>
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -173,8 +189,8 @@
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
<defs>
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#22c55e" />
<stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#2b8fd9" />
</linearGradient>
</defs>
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
@@ -219,8 +235,8 @@
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
<defs>
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#22c55e" />
<stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#2b8fd9" />
</linearGradient>
</defs>
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
@@ -380,6 +396,9 @@
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
<span class="run-mode-dot opencode"></span>OpenCode
</button>
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
<span class="run-mode-dot codex"></span>Codex
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
@@ -490,7 +509,8 @@
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
@@ -581,6 +601,29 @@
</div>
</div>
<!-- Ultracode / Workflow agents master-detail panel (opt-in via showUltracodeAgents) -->
<div class="subagents-panel ultracode-agents-panel hidden" id="ultracodeAgentsPanel">
<div class="subagents-panel-header">
<div class="subagents-panel-title">
Ultracode Agents <span id="ultracodeCountBadge" class="subagent-badge"></span>
</div>
<div class="subagents-panel-actions">
<button class="btn-icon-sm" onclick="app.toggleUltracodeAgentsPanel()" title="Toggle panel">&#x25B2;</button>
<button class="btn-icon-sm" onclick="app.closeUltracodeAgentsPanel()" title="Close">&times;</button>
</div>
</div>
<div class="subagents-panel-body">
<div class="subagent-container">
<div class="subagent-list ultracode-run-list" id="ultracodeRunList">
<div class="subagent-empty">No ultracode runs detected</div>
</div>
<div class="subagent-detail ultracode-agent-grid" id="ultracodeAgentGrid">
<div class="subagent-empty">Select a run to view its agents</div>
</div>
</div>
</div>
</div>
<!-- Session Options Modal (includes Respawn Settings) -->
<div class="modal" id="sessionOptionsModal">
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
@@ -601,17 +644,19 @@
<div class="modal-tab-content" id="respawn-tab">
<!-- Respawn Settings Section -->
<div class="session-respawn-section" id="sessionRespawnSection">
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
<div class="auto-resume-box">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
<span>Auto-resume when usage limit resets</span>
</label>
<span class="auto-resume-status" id="autoResumeStatus"></span>
<span class="form-hint">If Claude pauses on a usage limit ("limit reached &middot; resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
</div>
<div class="respawn-loop-box">
<div class="respawn-loop-title">Respawn loop</div>
<span class="form-hint respawn-loop-hint">One autonomous work cycle: whenever Claude goes idle, Codeman sends the update prompt, optionally runs /clear + /init, and kickstarts the next round &mdash; repeating for the chosen duration. All settings below belong to this loop; configure them, then press Enable.</span>
<div class="form-row">
<label>Duration</label>
<div class="duration-presets">
@@ -648,10 +693,10 @@
<p class="form-hint" id="presetDescriptionHint"></p>
</div>
<div class="form-section-header">Respawn Cycle</div>
<div class="form-section-header">Cycle Steps</div>
<div class="form-row">
<label>1. Update Prompt</label>
<textarea id="modalRespawnPrompt" rows="3" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 60px;">update all the docs and CLAUDE.md</textarea>
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
</div>
<div class="respawn-options-row" style="margin: 8px 0;">
@@ -663,22 +708,29 @@
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
<span>3. Send /init</span>
</label>
</div>
<div class="form-row">
<label>4. Kickstart Prompt</label>
<textarea id="modalRespawnKickstart" rows="2" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 40px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
</div>
<div class="form-section-header">Behavior</div>
<div class="respawn-options-row">
<label class="checkbox-inline">
<label class="checkbox-inline" title="Presses Enter for plan approvals and default question options">
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
<span>Auto-accept prompts</span>
</label>
</div>
<span class="form-hint">Auto-accept presses Enter for plan approvals and default question options</span>
<div class="form-row">
<label>4. Kickstart Prompt</label>
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle &middot; Auto-accept presses Enter for plan approvals and default options</span>
</div>
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
</div>
</div><!-- End respawn-loop-box -->
</div>
</div><!-- End respawn-tab -->
@@ -891,6 +943,7 @@
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="settings-display">Display</button>
<button class="modal-tab-btn" data-tab="settings-claude">Claude CLI</button>
<button class="modal-tab-btn" data-tab="settings-codex">Codex CLI</button>
<button class="modal-tab-btn" data-tab="settings-models">Models</button>
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
@@ -900,6 +953,16 @@
<!-- Display Tab -->
<div class="modal-tab-content" id="settings-display">
<div class="settings-grid">
<!-- Appearance Section -->
<div class="settings-section-header">Appearance</div>
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
<span class="settings-item-label">Skin</span>
<select id="appSettingsSkin" class="form-select">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
</select>
</div>
<!-- Input Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
@@ -946,6 +1009,13 @@
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
<div class="settings-item" title="Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.">
<span class="settings-item-label">Plan Usage Limits</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowPlanUsageLimits">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show A-/A+ font size buttons in header">
<span class="settings-item-label">Font Controls</span>
<label class="switch switch-sm">
@@ -960,20 +1030,6 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show total token count in header">
<span class="settings-item-label">Token Count</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowTokenCount" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show estimated cost next to token count">
<span class="settings-item-label">Show Cost ($)</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowCost">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show session lifecycle log button in header">
<span class="settings-item-label">Lifecycle Log</span>
<label class="switch switch-sm">
@@ -981,6 +1037,20 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the response viewer (eye) button in header">
<span class="settings-item-label">Response Viewer</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowResponseViewer">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
<span class="settings-item-label">Attachments Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowAttachmentsButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
<span class="settings-item-label">Multi-monitor Button</span>
<label class="switch switch-sm">
@@ -1029,6 +1099,20 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
<span class="settings-item-label">Ultracode Agents</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowUltracodeAgents">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Pop a floating window for each active ultracode / Workflow run, connected by a line to its session tab (additional to the Ultracode Agents panel)">
<span class="settings-item-label">Ultracode Floating Windows</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsUltracodeFloatingWindows">
<span class="slider"></span>
</label>
</div>
<!-- Subagent Options Section -->
<div class="settings-section-header">Subagent Options</div>
@@ -1133,13 +1217,28 @@
</label>
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
</div>
<div class="form-row">
<label>Claude Model</label>
<select id="appSettingsClaudeModel" class="form-select">
<option value="">Default (CLI setting)</option>
<option value="claude-fable-5[1m]">Fable 5 (1M context)</option>
<option value="claude-fable-5">Fable 5</option>
<option value="opus[1m]">Opus (1M context)</option>
<option value="opus">Opus</option>
<option value="claude-opus-4-6[1m]">Opus 4.6 (1M context)</option>
<option value="claude-opus-4-6">Opus 4.6</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
</select>
<span class="form-hint">Model for new Claude sessions (pinned via the case's .claude/settings.local.json) — takes precedence over the 1M Opus toggle below</span>
</div>
<div class="form-row form-row-switch">
<label>1M Opus Context</label>
<label class="switch">
<input type="checkbox" id="appSettingsOpusContext1m">
<span class="slider"></span>
</label>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions — ignored when a Claude Model is selected above</span>
</div>
<div class="form-row">
<label>Thinking Effort</label>
@@ -1170,12 +1269,25 @@
<span class="form-hint">Process priority (-20 to 19, higher = lower priority, default: 10)</span>
</div>
</div>
<!-- Codex CLI Tab -->
<div class="modal-tab-content hidden" id="settings-codex">
<div class="form-section-header">Codex CLI</div>
<div class="form-row form-row-switch">
<label>Bypass Approvals and Sandbox</label>
<label class="switch">
<input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals">
<span class="slider"></span>
</label>
<span class="form-hint">Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox</span>
</div>
</div>
<!-- Models Tab -->
<div class="modal-tab-content hidden" id="settings-models">
<div class="form-row">
<label>Default Model</label>
<select id="appSettingsDefaultModel" class="form-select">
<option value="">Default (CLI default)</option>
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
<option value="opus">Opus (Most capable)</option>
<option value="sonnet">Sonnet (Balanced)</option>
<option value="haiku">Haiku (Fast & cheap)</option>
@@ -1199,6 +1311,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Quick searches, codebase exploration</span>
</div>
@@ -1209,6 +1322,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Code writing, feature implementation</span>
</div>
@@ -1219,6 +1333,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Writing and running tests</span>
</div>
@@ -1229,6 +1344,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5">Fable 5</option>
</select>
<span class="form-hint">Code review, quality checks</span>
</div>
@@ -1840,6 +1956,8 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script>
@@ -1847,10 +1965,12 @@
<script defer src="orchestrator-panel.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
<script defer src="ultracode-windows.js"></script>
<script defer src="image-input.js"></script>
</body>
</html>
+70 -5
View File
@@ -41,6 +41,8 @@
// eslint-disable-next-line no-unused-vars
const CjkInput = (() => {
let _textarea = null;
let _terminalContainer = null;
let _xtermTextarea = null;
let _send = null;
let _initialized = false;
let _composing = false;
@@ -75,6 +77,23 @@ const CjkInput = (() => {
_textarea.setSelectionRange(1, 1);
}
function _isMobileComposer() {
return !!(
_textarea &&
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice() &&
_textarea.classList.contains('cjk-input-visible')
);
}
function _resetInput() {
if (_isMobileComposer()) {
_textarea.value = '';
} else {
_resetToPhantom();
}
}
/** Check if textarea contains only phantom(s) or is empty — no real user text */
function _isEffectivelyEmpty() {
return !_strip(_textarea.value);
@@ -97,24 +116,44 @@ const CjkInput = (() => {
_composing = false;
_textarea = document.getElementById('cjkInput');
if (!_textarea) return this;
_terminalContainer = document.getElementById('terminalContainer');
// Seed the phantom character
_resetToPhantom();
// Seed the phantom character for the hidden/immediate CJK path.
_resetInput();
_listeners.mousedown = (e) => { e.stopPropagation(); };
_listeners.focus = () => {
window.cjkActive = true;
if (_isMobileComposer() && _textarea.value === PHANTOM) {
_textarea.value = '';
return;
}
// Restore phantom if textarea was emptied while blurred
if (!_textarea.value) _resetToPhantom();
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
};
_listeners.blur = () => { window.cjkActive = false; };
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('focus', _listeners.focus);
_textarea.addEventListener('blur', _listeners.blur);
_listeners.xtermFocusRedirect = () => {
if (!_isMobileComposer()) return;
_textarea.focus();
};
if (_terminalContainer) {
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
if (_xtermTextarea) {
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
}
}
// ── Composition tracking ──
_listeners.compositionstart = () => {
_composing = true;
if (_isMobileComposer()) {
if (_textarea.value === PHANTOM) _textarea.value = '';
return;
}
// Clear phantom so IME sees a clean textarea — some IMEs include
// existing text in the composition region which would corrupt input.
if (_textarea.value === PHANTOM) {
@@ -123,6 +162,7 @@ const CjkInput = (() => {
};
_listeners.compositionend = () => {
_composing = false;
if (_isMobileComposer()) return;
// Defer flush: some Android IMEs haven't committed text to textarea
// when compositionend fires. setTimeout(0) ensures we read the final value.
setTimeout(_flush, 0);
@@ -145,7 +185,7 @@ const CjkInput = (() => {
} else {
_send('\r');
}
_resetToPhantom();
_resetInput();
return;
}
@@ -153,7 +193,7 @@ const CjkInput = (() => {
if (e.key === 'Escape') {
e.preventDefault();
_composing = false;
_resetToPhantom();
_resetInput();
return;
}
@@ -167,6 +207,19 @@ const CjkInput = (() => {
// Below: only when NOT composing (composing keystrokes belong to IME)
if (_composing) return;
if (_isMobileComposer()) {
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
e.preventDefault();
_send('\x7f');
return;
}
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();
_send(PASSTHROUGH_KEYS[e.key]);
}
return;
}
// Backspace: forward to PTY when no real text in textarea
// (Desktop path — Android uses the input event + phantom approach)
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
@@ -198,6 +251,13 @@ const CjkInput = (() => {
// making keydown unreliable. input fires AFTER character insertion and
// carries inputType which tells us whether the text is final or tentative.
_listeners.input = (e) => {
if (_isMobileComposer()) {
if (_textarea.value.includes(PHANTOM)) {
_textarea.value = _strip(_textarea.value);
}
return;
}
// ── Backspace / delete detection ──
// Android long-press backspace generates rapid deleteContentBackward events.
// The phantom character ensures the textarea is never truly empty, so each
@@ -245,8 +305,13 @@ const CjkInput = (() => {
if (handler) _textarea.removeEventListener(event, handler);
}
}
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
}
window.cjkActive = false;
_composing = false;
_terminalContainer = null;
_xtermTextarea = null;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
+3 -2
View File
@@ -4,7 +4,7 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
@@ -37,7 +37,7 @@ const KeyboardAccessoryBar = {
element: null,
_mode: 'simple', // 'simple' or 'extended'
/** HTML for simple mode: arrows, commands, paste, dismiss */
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
_simpleButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@@ -57,6 +57,7 @@ const KeyboardAccessoryBar = {
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
+36 -9
View File
@@ -139,6 +139,9 @@ const MobileDetection = {
resizeTimeout = setTimeout(() => {
this.updateBodyClass();
this.updateAppHeight();
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
}, 100);
};
window.addEventListener('resize', this._resizeHandler);
@@ -255,10 +258,9 @@ const KeyboardHandler = {
if (heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
document.body.classList.add('keyboard-visible');
// Restore --app-height: MobileDetection's resize listener fires before ours
// and may have already shrunk it for the keyboard viewport change.
// Use initialViewportHeight (captured before keyboard opened).
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
this.onKeyboardShow();
}
// Keyboard hidden (viewport grew back close to initial)
@@ -277,6 +279,8 @@ const KeyboardHandler = {
// state changes, orientation changes, and other viewport shifts
if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
} else {
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
}
this.updateLayoutForKeyboard();
@@ -295,6 +299,7 @@ const KeyboardHandler = {
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
if (this.keyboardVisible) {
@@ -302,6 +307,15 @@ const KeyboardHandler = {
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
//
// MUST measure against the LAYOUT viewport (window.innerHeight): the
// bars are position:fixed, which anchors to the layout viewport — on
// iOS that keeps its full height while the keyboard is open. Measuring
// the shrunken .app instead (its height tracks --app-height = visual
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
// and accessory bar behind the OS keyboard (0.9.8 regression). On
// Android the layout viewport itself shrinks with the keyboard, so
// innerHeight === visualBottom and the offset is naturally 0 there.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
@@ -317,13 +331,17 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
// Shrink main content area so terminal doesn't extend behind keyboard.
// Use stable keyboard height (not scroll-dependent) for padding.
// 84px = toolbar (40px) + accessory bar (44px).
// Reserve only Codeman's visible controls. The OS keyboard is outside
// the visual viewport; adding its height here creates a large blank area
// above the mobile toolbar on iPhone.
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
if (main && keyboardHeight > 0) {
main.style.paddingBottom = `${keyboardHeight + 84}px`;
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
}
} else {
this.resetLayout();
@@ -334,6 +352,7 @@ const KeyboardHandler = {
resetLayout() {
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
if (toolbar) {
@@ -342,6 +361,9 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.transform = '';
}
if (cjkInput) {
cjkInput.style.transform = '';
}
if (main) {
main.style.paddingBottom = '';
}
@@ -376,6 +398,8 @@ const KeyboardHandler = {
// to the accessory bar.
this._shrinkPaddingToFit();
app.terminal.scrollToBottom();
app._syncMobileHelperTextareaToCursor?.();
app._localEchoOverlay?.rerender?.();
// Send resize to server so PTY dimensions match xterm
this._sendTerminalResize();
}
@@ -421,10 +445,13 @@ const KeyboardHandler = {
const cols = Math.max(dims.cols, 40);
const rows = Math.max(dims.rows, 10);
app._lastResizeDims = { cols, rows };
// Declare the viewport type so resize arbitration can ignore this
// while a desktop connection is sizing the same session.
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }),
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
}
} catch {}
+162 -28
View File
@@ -36,6 +36,13 @@ html.mobile-init .file-browser-panel {
html {
touch-action: manipulation;
}
/* No "open in new window" (detach) on phones/tablets — popped-out browser
windows aren't usable there. !important beats the hover/detached reveal
rules in styles.css */
.session-tab .tab-detach {
display: none !important;
}
}
/* ============================================================================
@@ -55,7 +62,9 @@ html.mobile-init .file-browser-panel {
padding-right: calc(0.5rem + var(--safe-area-right));
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
z-index: 200;
contain: none;
overflow: visible;
z-index: 1200;
}
/* iOS safe area adjustment for fixed header - header extends into notch area */
@@ -92,10 +101,19 @@ html.mobile-init .file-browser-panel {
gap: 0.25rem;
}
/* Inline, always-visible header utilities (eye / multimonitor). The
position:fixed collapsible tray (02fa3f3) had its expand toggle reverted on
master but left this container permanently `mobile-collapsed` → the response
viewer eye became unreachable on phones. Restored to the simple inline flow. */
.header-right {
gap: 0.35rem;
}
.btn-icon-header:not(.btn-sm) {
width: 44px;
height: 44px;
}
/* Compact session tabs — .tabs-two-rows override needed to match
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
.session-tabs,
@@ -170,6 +188,12 @@ html.mobile-init .file-browser-panel {
/* ---- Settings Modal: Tablet Optimizations ---- */
/* Modals must stack above the fixed tablet header (z-index 1200) so the
modal header with the close button stays visible */
.modal {
z-index: 1300;
}
.modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@@ -355,7 +379,8 @@ html.mobile-init .file-browser-panel {
overflow: hidden;
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
z-index: 200;
contain: none;
z-index: 1200;
}
/* iOS safe area adjustment for fixed header - header extends into notch area */
@@ -409,7 +434,9 @@ html.mobile-init .file-browser-panel {
height: 12px;
}
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar */
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log {
display: none !important;
@@ -445,7 +472,18 @@ html.mobile-init .file-browser-panel {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.6);
color: #ef4444;
animation: voice-pulse 1.2s ease-in-out infinite;
animation: mobile-voice-pulse 1.2s ease-in-out infinite;
}
@keyframes mobile-voice-pulse {
0%, 100% {
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.2);
}
50% {
box-shadow: inset 0 0 0 2px rgba(239, 68, 68, 0.75);
background: rgba(239, 68, 68, 0.35);
}
}
/* Mobile app settings gear in toolbar - far right */
@@ -491,7 +529,8 @@ html.mobile-init .file-browser-panel {
top: 0;
left: 0;
right: 0;
bottom: 0;
bottom: auto;
height: var(--app-height, 100dvh);
}
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
@@ -548,10 +587,10 @@ html.mobile-init .file-browser-panel {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.5rem;
font-size: 0.65rem;
line-height: 1;
width: 12px;
height: 12px;
width: 32px;
height: 32px;
margin-left: auto;
opacity: 0.6;
}
@@ -617,6 +656,10 @@ html.mobile-init .file-browser-panel {
align-items: center;
}
.toolbar-center .btn-toolbar.btn-voice {
display: none !important;
}
.toolbar-right {
display: none !important;
}
@@ -798,9 +841,17 @@ html.mobile-init .file-browser-panel {
.btn-toolbar.btn-shell {
flex: 0 0 auto;
min-width: fit-content;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 10px !important;
padding: 0 8px !important;
overflow: hidden;
font-size: 0 !important;
}
.btn-toolbar.btn-shell::after {
content: "Shell";
font-size: 0.65rem;
}
/* Mobile case button - visible on mobile */
@@ -843,6 +894,24 @@ html.mobile-init .file-browser-panel {
color: #fff;
}
@media (max-width: 374px) {
.toolbar {
padding: 0 2px;
}
.toolbar-left,
.toolbar-left .toolbar-group,
.toolbar-left .toolbar-group:first-child {
gap: 2px;
}
}
@media (max-width: 430px) {
.btn-case-settings-mobile {
display: none !important;
}
}
/* Mobile case settings popover */
.case-settings-popover-mobile {
position: fixed;
@@ -1120,6 +1189,13 @@ html.mobile-init .file-browser-panel {
max-height: 35vh;
}
/* Modals must stack above the fixed mobile header (z-index 1200), or the
modal header with the close button is buried underneath it and the
full-screen modal cannot be dismissed */
.modal {
z-index: 1300;
}
/* Full-screen modals on phones */
.modal-content {
width: 100%;
@@ -1243,6 +1319,16 @@ html.mobile-init .file-browser-panel {
font-size: 0.85rem;
}
.history-show-more {
display: block;
margin-bottom: 0.75rem;
}
.welcome-ralph-link {
display: block;
margin: 0.75rem auto 0;
}
.welcome-hint {
font-size: 0.7rem;
margin-top: 0.75rem;
@@ -1423,42 +1509,36 @@ html.mobile-init .file-browser-panel {
border-radius: 5px;
}
/* Duration preset buttons — grid layout, 4 columns for even spacing */
/* Duration preset buttons — one compact row */
.duration-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
}
.duration-preset-btn {
min-height: 32px;
padding: 0.2rem 0.25rem;
font-size: 0.65rem;
border-radius: 5px;
min-height: 24px;
padding: 0.1rem 0.4rem;
font-size: 0.6rem;
border-radius: 4px;
text-align: center;
}
/* Custom duration — spans full row below */
.duration-custom {
grid-column: 1 / -1;
display: flex;
gap: 0.25rem;
gap: 0.2rem;
align-items: center;
}
.duration-custom .duration-preset-btn {
flex: 0 0 auto;
min-width: 50px;
}
.duration-custom-input.visible {
flex: 1;
flex: 0 1 auto;
}
.duration-custom-input input {
width: 100%;
min-height: 28px;
width: 64px;
min-height: 24px;
font-size: 16px; /* Prevents iOS zoom */
padding: 0.1rem 0.3rem;
}
/* Preset selector row — full-width dropdown, buttons below */
@@ -1517,6 +1597,16 @@ html.mobile-init .file-browser-panel {
height: 18px;
}
/* Respawn-loop box title matches the auto-resume title; its cycle-step
checkboxes match the step labels */
#sessionOptionsModal .respawn-loop-title {
font-size: 0.75rem;
}
#sessionOptionsModal .respawn-loop-box .checkbox-inline {
font-size: 0.65rem;
}
/* Respawn options row — stack if needed */
#sessionOptionsModal .respawn-options-row {
gap: 0.5rem;
@@ -2217,3 +2307,47 @@ html.mobile-init .file-browser-panel {
display: block;
}
}
@media (max-width: 430px) {
/* Attachment history (COD-18): full-screen sheet on phones */
.attachment-history-drawer {
top: 0;
bottom: 0;
width: 100%;
max-width: 100%;
height: 100vh;
height: 100dvh;
border-left: none;
border-radius: 0;
padding-top: var(--safe-area-top);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
padding-bottom: var(--safe-area-bottom);
}
.attachment-history-header {
padding: 12px;
}
.attachment-history-list {
padding: 6px;
}
.attachment-history-item {
grid-template-columns: 104px minmax(0, 1fr);
gap: 8px;
padding: 8px 6px;
}
.attachment-history-thumb {
width: 104px;
}
.attachment-history-actions {
gap: 4px;
}
.attachment-history-actions button {
padding: 4px 7px;
}
}
+3 -3
View File
@@ -182,7 +182,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({ goal, config }),
});
const data = await res.json();
if (data.ok) {
if (data.data?.ok) {
this.orchestratorState = { state: 'planning', plan: null };
this.showOrchestratorPanel();
this.renderOrchestratorPanel();
@@ -259,8 +259,8 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch('/api/orchestrator/status');
const data = await res.json();
if (data.ok) {
this.orchestratorState = data;
if (data.data?.ok) {
this.orchestratorState = data.data;
this.renderOrchestratorPanel();
}
} catch (err) {
+516 -32
View File
@@ -251,7 +251,7 @@ Object.assign(CodemanApp.prototype, {
const response = await fetch('/api/token-stats');
const data = await response.json();
if (data.success) {
this.renderTokenStats(data);
this.renderTokenStats(data.data);
document.getElementById('tokenStatsModal').classList.add('active');
} else {
this.showToast('Failed to load token stats', 'error');
@@ -380,6 +380,9 @@ Object.assign(CodemanApp.prototype, {
panel.classList.toggle('open');
if (panel.classList.contains('open')) {
// applyMonitorVisibility() sets inline display:none when the "Show Monitor"
// setting is off — clear it so transient opens (session-tab task badge) work
panel.style.display = '';
// Load screens and start stats collection
await this.loadMuxSessions();
await fetch('/api/mux-sessions/stats/start', { method: 'POST' });
@@ -1547,29 +1550,7 @@ Object.assign(CodemanApp.prototype, {
}
const terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12,
lineHeight: 1.2,
@@ -2462,8 +2443,8 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
},
async openFilePreview(filePath) {
if (!this.activeSessionId || !filePath) return;
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
if (!sessionId || !filePath) return;
const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle');
@@ -2478,8 +2459,72 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
footerEl.textContent = '';
const ext = (filePath.split('.').pop() || '').toLowerCase();
// 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();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
} 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`);
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
}
return;
}
// Workspace-path (auto-detected, unregistered) attachments: Office docs are
// converted to PDF server-side via the file-preview route; PDFs stream raw.
// Both render inline in an iframe. Without this, docx/pptx/pdf fall through
// to file-content below, which would dump the binary bytes as mojibake.
if (ext === 'docx' || ext === 'pptx') {
footerEl.textContent = ext.toUpperCase();
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
if (ext === 'pdf') {
footerEl.textContent = 'PDF';
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
// SVG renders as an image, but file-raw deliberately serves SVG as an
// untrusted octet-stream attachment (XSS hardening), so a direct
// <img src=file-raw> would break. Fetch the bytes and render via a
// same-origin blob typed image/svg+xml — <img> never executes scripts in
// the referenced SVG, so this is safe while still rendering the graphic.
if (ext === 'svg') {
footerEl.textContent = 'SVG';
try {
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed to load image');
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
const img = bodyEl.querySelector('img');
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
return;
}
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
if (!res.ok) throw new Error('Failed to load file');
const result = await res.json();
@@ -2493,8 +2538,12 @@ Object.assign(CodemanApp.prototype, {
} else if (data.type === 'video') {
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview</div>`;
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
footerEl.textContent = data.extension || 'binary';
} else {
// Text content
@@ -2517,6 +2566,441 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = '';
},
// ═══════════════════════════════════════════════════════════════
// Attachment Cards (detected documents/images)
// ═══════════════════════════════════════════════════════════════
// SSE `attachment:detected` consumer: surface a dismissible card for the file
// and bump the per-session history unread count (refreshing the open drawer).
_onAttachmentDetected(data) {
console.log('[Attachment Detected]', data);
this.addAttachmentCard(data);
if (data.sessionId) {
const current =
this.attachmentHistoryCounts.get(data.sessionId) ??
this.sessions.get(data.sessionId)?.attachmentHistory?.length ??
0;
this.attachmentHistoryCounts.set(data.sessionId, Math.min(current + 1, 100));
if (data.sessionId === this.activeSessionId) {
this.updateAttachmentHistoryBadge();
if (this.attachmentHistoryDrawerOpen) {
this._debouncedCall(
'attachmentHistoryRefresh',
() => {
// The drawer may have closed or the active session changed during
// the debounce window — don't refresh for a stale session.
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === data.sessionId) {
this.loadAttachmentHistory(data.sessionId);
}
},
250
);
}
}
}
},
// Lazily create the floating stack the cards live in (appended to <body>).
ensureAttachmentCardStack() {
let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack');
if (!stack) {
stack = document.createElement('div');
stack.id = 'attachmentCardStack';
stack.className = 'attachment-card-stack';
document.body.appendChild(stack);
}
this.attachmentCardStack = stack;
return stack;
},
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
const url = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
window.open(url, '_blank');
},
addAttachmentCard(attachmentEvent) {
const {
sessionId,
relativePath,
fileName,
timestamp,
size,
attachmentType,
extension,
attachmentId,
rawUrl,
previewUrl,
thumbnailUrl,
} = attachmentEvent;
const filePath = relativePath || fileName;
const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`;
if (this.attachmentCards.has(cardId)) {
const existing = this.attachmentCards.get(cardId);
existing.element.focus?.();
return;
}
const MAX_ATTACHMENT_CARDS = 10;
if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) {
const oldestId = this.attachmentCards.keys().next().value;
if (oldestId) this.closeAttachmentCard(oldestId);
}
const stack = this.ensureAttachmentCardStack();
const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8);
const attachmentRawUrl =
rawUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
const attachmentPreviewUrl =
previewUrl ||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
const attachmentThumbnailUrl =
thumbnailUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
const card = document.createElement('article');
card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`;
card.tabIndex = 0;
card.dataset.attachmentId = cardId;
card.dataset.previewUrl = attachmentPreviewUrl || '';
card.innerHTML = `
<div class="attachment-thumbnail">
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
</div>
<div class="attachment-card-main">
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
<div class="attachment-file-meta">
<span>${escapeHtml(sessionName)}</span>
<span>${this.formatFileSize(size || 0)}</span>
</div>
<div class="attachment-actions">
<button type="button" class="attachment-preview-btn">Preview</button>
<a href="${escapeHtml(downloadUrl)}">Download</a>
<button type="button" class="attachment-open-btn">Open</button>
</div>
</div>
<button type="button" class="attachment-close-btn" title="Dismiss">&times;</button>
`;
const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img');
if (attachmentThumbnailImg) {
attachmentThumbnailImg.onerror = () => {
attachmentThumbnailImg.remove();
card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible');
};
}
card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => {
this.openFilePreview(filePath, sessionId, attachmentId || null);
});
card.querySelector('.attachment-open-btn')?.addEventListener('click', () => {
this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null);
});
card.querySelector('.attachment-close-btn')?.addEventListener('click', () => {
this.closeAttachmentCard(cardId);
});
stack.prepend(card);
this.attachmentCards.set(cardId, { element: card, sessionId, filePath });
this._refreshAttachmentClearAll();
},
// Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and
// closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1.
_refreshAttachmentClearAll() {
const stack = this.attachmentCardStack;
if (!stack) return;
let control = stack.querySelector('.attachment-clear-all');
if (this.attachmentCards.size < 2) {
if (control) control.hidden = true;
return;
}
if (!control) {
control = document.createElement('button');
control.type = 'button';
control.className = 'attachment-clear-all';
control.textContent = 'Clear all';
control.title = 'Dismiss all attachment cards';
control.addEventListener('click', () => this.closeAllAttachmentCards());
stack.prepend(control);
}
control.hidden = false;
},
closeAttachmentCard(attachmentId) {
const cardData = this.attachmentCards.get(attachmentId);
if (!cardData) return;
cardData.element.remove();
this.attachmentCards.delete(attachmentId);
if (this.attachmentCardStack && this.attachmentCards.size === 0) {
this.attachmentCardStack.remove();
this.attachmentCardStack = null;
} else {
this._refreshAttachmentClearAll();
}
},
closeAllAttachmentCards() {
for (const attachmentId of [...this.attachmentCards.keys()]) {
this.closeAttachmentCard(attachmentId);
}
},
closeSessionAttachmentCards(sessionId) {
const toClose = [];
for (const [attachmentId, data] of this.attachmentCards) {
if (data.sessionId === sessionId) toClose.push(attachmentId);
}
for (const attachmentId of toClose) {
this.closeAttachmentCard(attachmentId);
}
},
// ═══════════════════════════════════════════════════════════════
// Attachment History Drawer
// ═══════════════════════════════════════════════════════════════
updateAttachmentHistoryBadge(count = null) {
const badge = document.getElementById('attachmentHistoryBadge');
const button = document.getElementById('attachmentsHistoryBtn');
const sessionId = this.activeSessionId;
const nextCount = count ?? (sessionId ? this.attachmentHistoryCounts.get(sessionId) || 0 : 0);
if (badge) {
badge.textContent = nextCount > 99 ? '99+' : String(nextCount);
badge.style.display = nextCount > 0 ? '' : 'none';
}
if (button) {
button.classList.toggle('active', this.attachmentHistoryDrawerOpen);
button.setAttribute('aria-expanded', this.attachmentHistoryDrawerOpen ? 'true' : 'false');
}
},
ensureAttachmentHistoryDrawer() {
let drawer = document.getElementById('attachmentHistoryDrawer');
if (drawer) return drawer;
drawer = document.createElement('aside');
drawer.id = 'attachmentHistoryDrawer';
drawer.className = 'attachment-history-drawer';
drawer.setAttribute('aria-label', 'Attachment history');
drawer.innerHTML = `
<div class="attachment-history-header">
<div>
<div class="attachment-history-title">Attachments</div>
<div class="attachment-history-subtitle" id="attachmentHistorySubtitle">0 files</div>
</div>
<div class="attachment-history-header-actions">
<button type="button" class="btn-icon-sm" id="attachmentHistoryRefreshBtn" title="Refresh" aria-label="Refresh attachments">&#x21BB;</button>
<button type="button" class="btn-icon-sm" id="attachmentHistoryCloseBtn" title="Close" aria-label="Close attachments">&times;</button>
</div>
</div>
<div class="attachment-history-list" id="attachmentHistoryList"></div>
`;
document.body.appendChild(drawer);
drawer.querySelector('#attachmentHistoryRefreshBtn')?.addEventListener('click', () => {
this.loadAttachmentHistory(this.activeSessionId);
});
drawer.querySelector('#attachmentHistoryCloseBtn')?.addEventListener('click', () => {
this.closeAttachmentHistory();
});
return drawer;
},
async toggleAttachmentHistory() {
if (this.attachmentHistoryDrawerOpen) {
this.closeAttachmentHistory();
return;
}
await this.openAttachmentHistory();
},
async openAttachmentHistory() {
const drawer = this.ensureAttachmentHistoryDrawer();
this.attachmentHistoryDrawerOpen = true;
drawer.classList.add('open');
this.updateAttachmentHistoryBadge();
await this.loadAttachmentHistory(this.activeSessionId);
},
closeAttachmentHistory() {
const drawer = document.getElementById('attachmentHistoryDrawer');
this.attachmentHistoryDrawerOpen = false;
drawer?.classList.remove('open');
// Cancel any pending debounced refresh so it can't fire against a closed drawer.
if (this._debounceTimers?.attachmentHistoryRefresh) {
clearTimeout(this._debounceTimers.attachmentHistoryRefresh);
this._debounceTimers.attachmentHistoryRefresh = null;
}
this.updateAttachmentHistoryBadge();
},
async loadAttachmentHistory(sessionId = this.activeSessionId) {
const drawer = this.ensureAttachmentHistoryDrawer();
const list = drawer.querySelector('#attachmentHistoryList');
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
if (!list || !subtitle) return;
if (!sessionId) {
this.attachmentHistoryItems = [];
subtitle.textContent = 'No session';
list.innerHTML = '<div class="attachment-history-empty">No active session</div>';
this.updateAttachmentHistoryBadge(0);
return;
}
list.innerHTML = '<div class="attachment-history-empty">Loading...</div>';
try {
const res = await fetch(`/api/sessions/${sessionId}/attachments`);
if (!res.ok) throw new Error('Failed to load attachments');
const result = await res.json();
if (!result.success) throw new Error(result.error || 'Failed to load attachments');
const items = result.data?.items || [];
this.attachmentHistoryItems = items;
this.attachmentHistoryCounts.set(sessionId, items.length);
this.updateAttachmentHistoryBadge(items.length);
this.renderAttachmentHistory(items);
} catch (err) {
console.error('Failed to load attachment history:', err);
subtitle.textContent = 'Unavailable';
list.innerHTML = `<div class="attachment-history-empty">Error: ${escapeHtml(err.message)}</div>`;
}
},
renderAttachmentHistory(items = this.attachmentHistoryItems || []) {
const drawer = this.ensureAttachmentHistoryDrawer();
const list = drawer.querySelector('#attachmentHistoryList');
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
if (!list || !subtitle) return;
subtitle.textContent = `${items.length} ${items.length === 1 ? 'file' : 'files'}`;
if (items.length === 0) {
list.innerHTML = `
<div class="attachment-history-empty">
<div class="attachment-history-empty-title">No attachments yet</div>
<div>Show a file here by running:</div>
<code>codeman attach /absolute/path/to/file.pptx</code>
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
</div>
`;
return;
}
list.innerHTML = items.map((item) => this.renderAttachmentHistoryItem(item)).join('');
list.querySelectorAll('.attachment-history-thumb-img').forEach((img) => {
img.onerror = () => {
img.remove();
const fallback = img.closest('.attachment-history-thumb')?.querySelector('.attachment-history-thumb-fallback');
fallback?.classList.add('visible');
};
});
list.querySelectorAll('[data-attachment-action]').forEach((button) => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-history-id');
const action = button.getAttribute('data-attachment-action');
if (!id || !action) return;
if (action === 'preview') this.previewAttachmentHistoryItem(id);
if (action === 'download') this.downloadAttachmentHistoryItem(id);
if (action === 'open') this.openAttachmentHistoryItem(id);
if (action === 'reshow') this.reshowAttachmentCard(id);
});
});
},
renderAttachmentHistoryItem(item) {
const typeLabel = (item.extension || item.attachmentType || 'file').toUpperCase();
const meta = [
item.source === 'external' ? 'published' : 'workspace',
this.formatFileSize(item.size || 0),
item.missing ? 'missing' : '',
]
.filter(Boolean)
.join(' • ');
const thumb =
item.thumbnailUrl && !item.missing
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
: '';
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
return `
<div class="attachment-history-item ${item.missing ? 'missing' : ''}" data-history-item="${escapeHtml(item.id)}">
<div class="attachment-history-thumb">
${thumb}
<div class="attachment-history-thumb-fallback ${thumb ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
</div>
<div class="attachment-history-item-main">
<div class="attachment-history-file-name" title="${escapeHtml(item.fileName)}">${escapeHtml(item.fileName)}</div>
<div class="attachment-history-meta">${escapeHtml(meta)}</div>
<div class="attachment-history-actions">
<button type="button" data-attachment-action="preview" data-history-id="${escapeHtml(item.id)}" ${disabled}>Preview</button>
<button type="button" data-attachment-action="download" data-history-id="${escapeHtml(item.id)}" ${disabled}>Download</button>
<button type="button" data-attachment-action="open" data-history-id="${escapeHtml(item.id)}" ${disabled}>Open</button>
<button type="button" data-attachment-action="reshow" data-history-id="${escapeHtml(item.id)}" ${disabled}>Card</button>
</div>
</div>
</div>
`;
},
getAttachmentHistoryItem(itemId) {
return (this.attachmentHistoryItems || []).find((item) => item.id === itemId) || null;
},
previewAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
const path = item.relativePath || item.fileName;
this.openFilePreview(path, item.sessionId, item.attachmentId || null);
// Close the drawer so the preview window is unobstructed.
this.closeAttachmentHistory();
},
openAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
if (item.rawUrl || item.url) {
window.open(item.rawUrl || item.url, '_blank');
return;
}
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
},
downloadAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing || !item.downloadUrl) return;
window.open(item.downloadUrl, '_blank');
},
reshowAttachmentCard(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
this.addAttachmentCard({
sessionId: item.sessionId,
relativePath: item.relativePath,
fileName: item.fileName,
// Use the item's own timestamp (not Date.now()) so the derived cardId is
// stable across clicks — re-showing focuses the existing card instead of
// stacking a duplicate.
timestamp: item.timestamp ?? Date.now(),
size: item.size,
attachmentType: item.attachmentType,
extension: item.extension,
attachmentId: item.attachmentId,
rawUrl: item.rawUrl,
previewUrl: item.previewUrl,
thumbnailUrl: item.thumbnailUrl,
});
},
copyFilePreviewContent() {
if (this.filePreviewContent) {
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
@@ -2881,7 +3365,7 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch('/api/mux-sessions');
const data = await res.json();
this.muxSessions = data.sessions || [];
this.muxSessions = data.data?.sessions || [];
this.renderMuxSessions();
} catch (err) {
console.error('Failed to load mux sessions:', err);
@@ -3109,8 +3593,8 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' });
const data = await res.json();
if (data.dead && data.dead.length > 0) {
this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning');
if (data.data?.dead && data.data.dead.length > 0) {
this.showToast(`Found ${data.data.dead.length} dead mux session(s)`, 'warning');
await this.loadMuxSessions();
} else {
this.showToast('All mux sessions are alive', 'success');
@@ -3220,7 +3704,7 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch('/api/system/stats');
const stats = await res.json();
this.updateSystemStatsDisplay(stats);
this.updateSystemStatsDisplay(stats.data);
} catch (err) {
// Silently fail - system stats are not critical
}
+3 -3
View File
@@ -996,14 +996,14 @@ Object.assign(CodemanApp.prototype, {
return;
}
const history = data.history || [];
const history = data.data.history || [];
if (history.length === 0) {
this.showToast('No plan history available', 'info');
return;
}
// Show history dropdown modal
this.showPlanHistoryModal(history, data.currentVersion);
this.showPlanHistoryModal(history, data.data.currentVersion);
} catch (err) {
this.showToast('Failed to load plan history: ' + err.message, 'error');
}
@@ -1035,7 +1035,7 @@ Object.assign(CodemanApp.prototype, {
onclick="app.rollbackToPlanVersion(${item.version})">
<div>
<span class="plan-history-version">v${item.version}</span>
<span class="plan-history-tasks">${item.taskCount || 0} tasks</span>
<span class="plan-history-tasks">${item.stats?.total ?? 0} tasks</span>
</div>
<span class="plan-history-time">${this.formatRelativeTime(item.timestamp)}</span>
</div>
+10 -7
View File
@@ -151,11 +151,11 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`);
const data = await res.json();
if (data.success && data.exists && data.todos?.length > 0) {
if (data.success && data.data.exists && data.data.todos?.length > 0) {
this.ralphWizardConfig.existingPlan = {
todos: data.todos,
stats: data.stats,
content: data.content,
todos: data.data.todos,
stats: data.data.stats,
content: data.data.content,
};
this.updateExistingPlanUI();
} else {
@@ -378,7 +378,10 @@ Object.assign(CodemanApp.prototype, {
prompt += `Output \`<promise>${config.completionPhrase}</promise>\` when done\n\n`;
prompt += '## If Stuck\n';
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
prompt += 'Output `<promise>BLOCKED</promise>` with explanation\n\n';
prompt += '## Status Reporting\n';
prompt += '• End every response with a `RALPH_STATUS` block (parsed by Codeman)';
// Show preview with highlighting (escape first, then apply formatting)
const escapedPrompt = escapeHtml(prompt);
@@ -1054,8 +1057,8 @@ Object.assign(CodemanApp.prototype, {
this.showToast(data.error || 'Failed to start', 'error');
return;
}
this.ralphClosedSessions.delete(data.sessionId);
await this.selectSession(data.sessionId);
this.ralphClosedSessions.delete(data.data.sessionId);
await this.selectSession(data.data.sessionId);
this.showToast(`Ralph Loop started in ${config.caseName}`, 'success');
} catch (err) {
console.error('Failed to start Ralph loop:', err);
+1 -1
View File
@@ -1041,7 +1041,7 @@ Object.assign(CodemanApp.prototype, {
return;
}
this.runSummaryData = data.summary;
this.runSummaryData = data.data.summary;
this.renderRunSummary();
} catch (err) {
console.error('Failed to load run summary:', err);
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview Allowlist-based HTML sanitizer for markdown-rendered, agent/transcript-derived
* content that is subsequently assigned via innerHTML (response viewer, attachment markdown
* preview, message bodies).
*
* Security (COD-56): the previous sanitizer was a hand-rolled DENYLIST — it removed a fixed
* set of tags (script/iframe/object/embed/form/base/meta/link/style), stripped on* attrs and a
* few dangerous URL schemes, then re-serialized. Denylists are mXSS-prone: they did not strip
* `svg`/`math` (which carry their own foreign-namespace parsing rules and can smuggle script via
* namespace confusion), did not strip `style` attributes (CSS `expression()`/`url(javascript:)`
* on legacy engines), and had no positive allowlist, so any tag/attribute not explicitly named
* survived. `marked` runs with raw-HTML passthrough, so crafted HTML echoed by an agent flows
* straight into this function.
*
* This module replaces that with DOMPurify (Cure53), an allowlist sanitizer that is the
* industry standard for mXSS defense. It is configured to allow exactly the tag/attribute set
* that markdown rendering legitimately produces (headings, lists, code, blockquotes, links,
* tables, images with safe src) and to FORBID `style`/`svg`/`math` plus all event handlers and
* dangerous URL schemes.
*
* Cross-environment: in the browser this file runs as a classic <script> after
* vendor/dompurify.min.js and wires `window.sanitizeMarkdownHtml`. The factory is also exported
* (window/globalThis + CommonJS) so a jsdom unit test can build a sanitizer bound to a
* jsdom-window DOMPurify instance and exercise the exact same config.
*
* @globals {function} sanitizeMarkdownHtml - (html:string) => string, sanitized HTML
* @globals {function} createMarkdownSanitizer - (DOMPurify) => sanitizeMarkdownHtml (for tests)
* @dependency vendor/dompurify.min.js (provides the global DOMPurify)
* @loadorder 5.6 of 15 — after input-cjk.js(5.5), before app.js(6) (app.js calls it)
*/
(function (root) {
'use strict';
/**
* Tags markdown rendering (marked, gfm) legitimately emits. Anything outside this set is
* dropped by DOMPurify. Deliberately excludes svg/math (mXSS foreign-namespace vectors) and
* form/embed/object/iframe/script/style (no place in rendered markdown).
*/
var ALLOWED_TAGS = [
'a',
'b',
'blockquote',
'br',
'caption',
'code',
'del',
'div',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'mark',
'ol',
'p',
'pre',
'q',
's',
'samp',
'span',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'ul',
'var',
];
/**
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
* blocks may carry `language-*` classes from marked.
*/
var ALLOWED_ATTR = [
'href',
'src',
'alt',
'title',
'class',
'id',
'name',
'colspan',
'rowspan',
'align',
'width',
'height',
'lang',
'dir',
'start',
'reversed',
'type',
];
/**
* Build a sanitizer bound to a specific DOMPurify instance. The browser passes the global
* DOMPurify; tests pass a jsdom-window-bound instance so the same config is exercised under
* vitest without a real browser.
*/
function createMarkdownSanitizer(DOMPurify) {
if (!DOMPurify || typeof DOMPurify.sanitize !== 'function') {
throw new Error('createMarkdownSanitizer: a DOMPurify instance is required');
}
var CONFIG = {
ALLOWED_TAGS: ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTR,
// Defense in depth even though style/svg/math are not in ALLOWED_TAGS: also forbid the
// foreign-namespace roots and style so config drift can't silently re-admit them.
FORBID_TAGS: ['style', 'svg', 'math', 'script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['style'],
// NOTE: do NOT set USE_PROFILES here. DOMPurify treats USE_PROFILES and
// ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — when a profile is set it
// RESETS the allow-lists to the full profile and silently ignores the curated
// lists above, widening the tag set far beyond what markdown emits. Relying on
// the explicit ALLOWED_TAGS/ALLOWED_ATTR keeps the tight allowlist in force;
// FORBID_TAGS/FORBID_ATTR remain as defense-in-depth. DOMPurify still applies
// its default safe-URI handling (blocks javascript:/vbscript:, allows
// http/https/mailto/tel + data: only on image tags).
ALLOW_DATA_ATTR: false,
ADD_ATTR: [],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep text content of any removed element (so stripping a stray tag doesn't eat prose),
// matching the previous serializer's behavior of dropping the element but not its text.
KEEP_CONTENT: true,
};
return function sanitizeMarkdownHtml(html) {
return DOMPurify.sanitize(html == null ? '' : String(html), CONFIG);
};
}
// Expose the factory for tests (and any non-browser consumer).
if (root) {
root.createMarkdownSanitizer = createMarkdownSanitizer;
// In the browser, vendor/dompurify.min.js has already defined the global DOMPurify.
if (root.DOMPurify && typeof root.DOMPurify.sanitize === 'function') {
root.sanitizeMarkdownHtml = createMarkdownSanitizer(root.DOMPurify);
}
}
// CommonJS export for the vitest/jsdom unit test.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
createMarkdownSanitizer: createMarkdownSanitizer,
ALLOWED_TAGS: ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTR,
};
}
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
+134 -27
View File
@@ -49,7 +49,7 @@ Object.assign(CodemanApp.prototype, {
// Load settings to get lastUsedCase (reuse shared promise if provided)
let lastUsedCase = null;
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null);
if (settings) {
lastUsedCase = settings.lastUsedCase || null;
}
@@ -58,7 +58,7 @@ Object.assign(CodemanApp.prototype, {
}
const res = await fetch('/api/cases');
const cases = await res.json();
const cases = (await res.json()).data;
this.cases = cases;
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
@@ -125,7 +125,7 @@ Object.assign(CodemanApp.prototype, {
async updateDirDisplayForCase(caseName) {
try {
const res = await fetch(`/api/cases/${caseName}`);
const data = await res.json();
const data = (await res.json()).data;
if (data.path) {
document.getElementById('dirDisplay').textContent = data.path;
document.getElementById('dirInput').value = data.path;
@@ -151,17 +151,21 @@ Object.assign(CodemanApp.prototype, {
return this.run();
},
/** Run using the selected mode (Claude Code or OpenCode) */
/** Run using the selected mode (Claude Code, OpenCode, or Codex) */
async run() {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return this.runOpenCode();
}
if (mode === 'codex') {
return this.runCodex();
}
return this.runClaude();
},
/** Get/set the run mode, persisted in localStorage */
get runMode() { return this._runMode || 'claude'; },
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
// this file — an object-literal getter here would be flattened to a static value by
// Object.assign (it copies values, not accessor descriptors).
setRunMode(mode) {
this._runMode = mode;
@@ -253,7 +257,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
}
},
@@ -304,7 +308,7 @@ Object.assign(CodemanApp.prototype, {
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = await caseRes.json();
let caseData = (await caseRes.json())?.data ?? {};
// Create the case if it doesn't exist
if (!caseData.path) {
@@ -350,8 +354,10 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : '';
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
// Step 1: Create all sessions in parallel
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
@@ -364,6 +370,13 @@ Object.assign(CodemanApp.prototype, {
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
// Plan-usage statusLine exporter (App Settings → Display). The server
// ADDS our exporter on create when true; when false it intentionally
// leaves any existing exporter in place (a per-repo settings.local.json
// is shared by sibling sessions, so create-with-false must not yank it
// — see the comment in session-routes create). Disabling the setting
// removes it via the App Settings toggle path (system-routes), not here.
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
})
}).then(r => r.json())
);
@@ -373,7 +386,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
sessionIds.push(result.session.id);
sessionIds.push(result.data.session.id);
}
firstSessionId = sessionIds[0];
@@ -452,7 +465,7 @@ Object.assign(CodemanApp.prototype, {
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = await caseRes.json();
let caseData = (await caseRes.json())?.data ?? {};
// Create the case if it doesn't exist
if (!caseData.path) {
@@ -501,7 +514,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
sessionIds.push(result.session.id);
sessionIds.push(result.data.session.id);
}
// Step 2: Start all shells in parallel
@@ -545,7 +558,7 @@ Object.assign(CodemanApp.prototype, {
try {
// Check if OpenCode is available
const statusRes = await fetch('/api/opencode/status');
const status = await statusRes.json();
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
@@ -570,8 +583,55 @@ Object.assign(CodemanApp.prototype, {
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (data.sessionId) {
await this.selectSession(data.sessionId);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
},
async runCodex() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
this.terminal.focus();
try {
const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
return;
}
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'codex',
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
renderMode: 'hybrid',
},
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
@@ -592,7 +652,7 @@ Object.assign(CodemanApp.prototype, {
this.editingSessionId = sessionId;
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn');
this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
@@ -621,7 +681,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for OpenCode sessions
const isOpenCode = session.mode === 'opencode';
const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
@@ -637,6 +697,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
// Populate auto-resume on usage limit (token pause control)
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -737,6 +801,39 @@ Object.assign(CodemanApp.prototype, {
} catch { /* silent */ }
},
async autoSaveAutoResume() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.autoResumeEnabled = enabled;
if (!enabled) session.autoResumeAt = undefined;
}
this.updateAutoResumeStatus(this.editingSessionId);
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
}
},
// Show "resumes at HH:MM" in the session options modal while a usage-limit
// pause is armed for the session being edited
updateAutoResumeStatus(sessionId) {
const el = document.getElementById('autoResumeStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
el.textContent = `Usage limit pause active — resumes at ${at}`;
el.classList.add('active');
} else {
el.textContent = '';
el.classList.remove('active');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
@@ -789,8 +886,8 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
const data = await res.json();
if (data.success && data.config) {
const c = data.config;
if (data.success && data.data && data.data.config) {
const c = data.data.config;
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
@@ -927,9 +1024,8 @@ Object.assign(CodemanApp.prototype, {
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
// If a previous rename somehow leaked (shouldn't happen, but defends against
// future code paths that throw before cleanup), abort it before starting fresh.
if (this._activeRename) this._activeRename.cancel();
// Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true;
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
@@ -957,14 +1053,14 @@ Object.assign(CodemanApp.prototype, {
input.focus();
input.select();
let settled = false;
const finishRename = async ({ commit }) => {
if (settled) return;
settled = true;
if (!this._inlineRenameActive) return; // prevent double-fire
this._inlineRenameActive = false;
this._activeRename = null;
// Aborted (e.g. session was deleted mid-rename): just re-render so any
// ghost DOM left behind is replaced with the canonical tab list.
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
// so any ghost DOM is replaced with the canonical tab list, and skip the
// API call — a cancel must not fire a stale rename PUT.
if (!commit) {
this.renderSessionTabs();
return;
@@ -1475,3 +1571,14 @@ Object.assign(CodemanApp.prototype, {
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
configurable: true,
enumerable: true,
get() {
return this._runMode || 'claude';
},
set(mode) {
this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
},
});
+292 -36
View File
@@ -185,9 +185,9 @@ Object.assign(CodemanApp.prototype, {
try {
// Get VAPID public key from server
const keyData = await this._apiJson('/api/push/vapid-key');
if (!keyData?.success) throw new Error('Failed to get VAPID key');
if (!keyData) throw new Error('Failed to get VAPID key');
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey);
const subscription = await this._swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
@@ -204,11 +204,11 @@ Object.assign(CodemanApp.prototype, {
pushPreferences: this._buildPushPreferences(),
},
});
if (!data?.success) throw new Error('Failed to register subscription');
if (!data) throw new Error('Failed to register subscription');
this._pushSubscription = subscription;
this._pushSubscriptionId = data.data.id;
localStorage.setItem('codeman-push-subscription-id', data.data.id);
this._pushSubscriptionId = data.id;
localStorage.setItem('codeman-push-subscription-id', data.id);
this._updatePushUI(true);
this.showToast('Push notifications enabled', 'success');
} catch (err) {
@@ -305,14 +305,19 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -326,7 +331,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
@@ -339,8 +344,12 @@ Object.assign(CodemanApp.prototype, {
claudeModeSelect.onchange = () => {
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
};
// Codex CLI settings
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
// CPU Priority settings
@@ -569,7 +578,7 @@ Object.assign(CodemanApp.prototype, {
const res = await this._apiPost('/api/system/update', {});
if (!res || !res.ok) {
let msg = 'Failed to start the update.';
try { const j = await res.json(); if (j?.error?.message) msg = j.error.message; } catch {}
try { const j = await res.json(); if (typeof j?.error === 'string' && j.error) msg = j.error; } catch {}
this._setUpdateProgress(`<span style="color:var(--danger,#e5534b)">${escapeHtml(msg)}</span>`);
if (btn) { btn.disabled = false; btn.textContent = 'Update now'; }
return;
@@ -598,7 +607,10 @@ Object.assign(CodemanApp.prototype, {
let data = null;
try {
const res = await fetch('/api/system/update/status');
if (res.ok) data = await res.json();
if (res.ok) {
const env = await res.json();
data = env && env.success === true ? env.data : env;
}
} catch { /* server restarting — keep polling */ }
if (!data) {
@@ -652,7 +664,8 @@ Object.assign(CodemanApp.prototype, {
async loadTunnelStatus() {
try {
const res = await fetch('/api/tunnel/status');
const status = await res.json();
const env = await res.json();
const status = env?.success === true ? env.data : env;
const active = status.running && status.url;
this._tunnelUrl = active ? status.url : null;
this._updateTunnelUrlDisplay(this._tunnelUrl);
@@ -721,7 +734,8 @@ Object.assign(CodemanApp.prototype, {
if (!res.ok) throw new Error('Tunnel not running');
return res.json();
})
.then(data => {
.then(env => {
const data = env?.success === true ? env.data : env;
const container = document.getElementById('tunnelQrContainer');
if (container && data.svg) container.innerHTML = data.svg;
// Show auth badge, countdown, and regenerate button when auth is enabled
@@ -754,7 +768,8 @@ Object.assign(CodemanApp.prototype, {
// Fetch URL for display
fetch('/api/tunnel/status')
.then(r => r.json())
.then(status => {
.then(env => {
const status = env?.success === true ? env.data : env;
const urlEl = document.getElementById('tunnelQrUrl');
if (urlEl && status.url) {
urlEl.textContent = status.url;
@@ -786,7 +801,8 @@ Object.assign(CodemanApp.prototype, {
_refreshTunnelQrFromApi() {
fetch('/api/tunnel/qr')
.then(res => res.ok ? res.json() : null)
.then(data => {
.then(env => {
const data = env?.success === true ? env.data : env;
if (!data?.svg) return;
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = data.svg;
@@ -832,11 +848,18 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
this._dismissTunnelConnecting();
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
@@ -901,7 +924,8 @@ Object.assign(CodemanApp.prototype, {
this._tunnelPollTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnel/status');
const status = await res.json();
const env = await res.json();
const status = env?.success === true ? env.data : env;
if (status.running && status.url) {
// Tunnel is up — update UI
this._dismissTunnelConnecting();
@@ -960,7 +984,7 @@ Object.assign(CodemanApp.prototype, {
}
fetch('/api/tunnel/qr')
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => { if (data.svg) qrInner.innerHTML = data.svg; })
.then(env => { const data = env?.success === true ? env.data : env; if (data.svg) qrInner.innerHTML = data.svg; })
.catch(() => { qrInner.innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; });
} else {
clearTimeout(this._welcomeQrShrinkTimer);
@@ -1032,7 +1056,8 @@ Object.assign(CodemanApp.prototype, {
// Fetch tunnel info
try {
const res = await fetch('/api/tunnel/info');
const info = await res.json();
const env = await res.json();
const info = env?.success === true ? env.data : env;
this._renderTunnelPanel(info);
} catch {
const body = document.getElementById('tunnelPanelBody');
@@ -1134,13 +1159,82 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
try {
const body = await res.json();
if (body && body.error) message = body.error;
} catch {
/* non-JSON body — use the default message */
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
const confirmed = confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
if (!confirmed) {
this._dismissTunnelConnecting?.();
this.showToast('Tunnel not enabled', 'info');
return true;
}
try {
const retry = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
});
if (retry.ok) {
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
return false; // proceed with the caller's success/connecting path
}
let m = 'Failed to enable tunnel.';
try {
const b = await retry.json();
if (b && b.error) m = b.error;
} catch {
/* non-JSON */
}
this._dismissTunnelConnecting?.();
this.showToast(m, 'error');
return true;
} catch {
this._dismissTunnelConnecting?.();
this.showToast('Failed to enable tunnel', 'error');
return true;
}
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
},
async _tunnelPanelToggle(enable) {
try {
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
@@ -1166,7 +1260,8 @@ Object.assign(CodemanApp.prototype, {
this.showToast('All sessions revoked', 'success');
// Refresh panel
const res = await fetch('/api/tunnel/info');
const info = await res.json();
const env = await res.json();
const info = env?.success === true ? env.data : env;
this._renderTunnelPanel(info);
} catch {
this.showToast('Failed to revoke sessions', 'error');
@@ -1261,7 +1356,8 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch(`/api/session-lifecycle?${params}`);
const data = await res.json();
const env = await res.json();
const data = env?.success === true ? env.data : env;
const tbody = document.getElementById('lifecycleTableBody');
const empty = document.getElementById('lifecycleEmpty');
@@ -1301,7 +1397,8 @@ Object.assign(CodemanApp.prototype, {
async saveAppSettings() {
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true;
const _prev = this.loadAppSettingsFromStorage();
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
@@ -1309,14 +1406,17 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
showCost: document.getElementById('appSettingsShowCost').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -1326,11 +1426,15 @@ Object.assign(CodemanApp.prototype, {
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Codex CLI settings
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
// CPU Priority settings
@@ -1340,6 +1444,16 @@ Object.assign(CodemanApp.prototype, {
},
};
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
// UI, but their features still read settings.showTokenCount / settings.showCost
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
// rebuilds `settings` fresh from the DOM (a full replacement, not a merge), so
// without this these keys would be DROPPED on every save and fall back to their
// defaults — silently re-enabling the token chip for anyone who'd turned it off,
// with no UI left to turn it back off. Preserve the prior stored preference.
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
@@ -1437,6 +1551,7 @@ Object.assign(CodemanApp.prototype, {
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
@@ -1449,11 +1564,42 @@ Object.assign(CodemanApp.prototype, {
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
// Save to server (includes notification prefs for cross-browser persistence).
// Strip device-specific DISPLAY keys so they never sync across devices —
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
// is per-device too (desktop can show the usage chip while mobile stays hidden).
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
// ENABLE only, so a device with the chip OFF never strips the exporter that
// another device's chip depends on — see system-routes settings handler).
const {
localEchoEnabled: _leo,
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
...serverSettings
} = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
const res = await this._apiPut('/api/settings', {
...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
return;
}
// Save model configuration separately
await this.saveModelConfigFromSettings();
@@ -1583,7 +1729,11 @@ Object.assign(CodemanApp.prototype, {
showProjectInsights: false,
showFileBrowser: false,
showSubagents: false,
showUltracodeAgents: false,
ultracodeFloatingWindows: false,
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1592,6 +1742,8 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
cjkInputEnabled: false,
skin: 'daylight-blue',
};
}
// Desktop defaults - rely on ?? operators in apply functions
@@ -1629,12 +1781,31 @@ Object.assign(CodemanApp.prototype, {
}
},
// Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH
// localStorage locations (the standalone 'codeman:skin' key the pre-paint head
// script reads + the app-settings blob field written by saveAppSettingsToStorage),
// updates window.__codemanSkin, and re-themes any live terminals.
applySkin() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
/* private mode */
}
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false;
const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true;
const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true;
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? true);
const fontControlsEl = document.querySelector('.header-font-controls');
const systemStatsEl = document.getElementById('headerSystemStats');
@@ -1657,6 +1828,22 @@ Object.assign(CodemanApp.prototype, {
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
}
// Hide the response viewer (eye) button when setting is disabled.
// Marker class, not inline style — the base rule is display:inline-flex !important.
const showResponseViewer = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
const responseViewerBtn = document.querySelector('.btn-response-viewer-header');
if (responseViewerBtn) {
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
}
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
// marker class, base is display:inline-flex !important.
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
const attachmentsBtn = document.getElementById('attachmentsHistoryBtn');
if (attachmentsBtn) {
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
}
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
@@ -1667,6 +1854,25 @@ Object.assign(CodemanApp.prototype, {
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
}
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
// from the mobile-header-buttons-policy guard.
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
const ultracodeBtn = document.querySelector('.btn-ultracode-agents');
if (ultracodeBtn) {
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
}
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
// Limits"). Server renders the initial state on reload; this handles a live
// toggle from a settings save. Marker class (base is display:inline-flex
// !important), matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
const planUsageChip = document.getElementById('planUsageChip');
if (planUsageChip) {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
}
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
@@ -1705,7 +1911,7 @@ Object.assign(CodemanApp.prototype, {
applyMonitorVisibility() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true;
const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? false;
const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false;
const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
@@ -1728,6 +1934,27 @@ Object.assign(CodemanApp.prototype, {
}
}
// Ultracode agents panel visibility (SYNCED setting — not in displayKeys)
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
const ultracodePanel = document.getElementById('ultracodeAgentsPanel');
if (ultracodePanel) {
if (showUltracodeAgents) {
ultracodePanel.classList.remove('hidden');
} else {
ultracodePanel.classList.remove('open');
ultracodePanel.classList.add('hidden');
}
}
// Floating ultracode run windows have their OWN opt-in (default OFF), independent of the
// docked panel above: pop active runs when enabled, tear them all down when disabled
// (additional layer — ultracode-windows.js).
const ultracodeFloatingWindows = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
if (ultracodeFloatingWindows) {
if (typeof this.syncAllUltracodeFloatingWindows === 'function') this.syncAllUltracodeFloatingWindows();
} else if (typeof this.removeAllUltracodeWindows === 'function') {
this.removeAllUltracodeWindows();
}
// File browser panel visibility
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
if (fileBrowserPanel) {
@@ -1849,8 +2076,27 @@ Object.assign(CodemanApp.prototype, {
},
async loadAppSettingsFromServer(settingsPromise = null) {
// One-time migration: showPlanUsageLimits became a per-device display setting.
// Before this, it synced from the server, so the (separate) mobile settings blob
// may carry a stale `true` the user never enabled on this device. Clear it once
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
if (
MobileDetection.getDeviceType() === 'mobile' &&
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
) {
const s = this.loadAppSettingsFromStorage();
if (s && s.showPlanUsageLimits) {
s.showPlanUsageLimits = false;
this.saveAppSettingsToStorage(s);
}
localStorage.setItem('codeman:planUsagePerDeviceMigrated', '1');
}
} catch {
/* best-effort migration */
}
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
@@ -1860,9 +2106,17 @@ Object.assign(CodemanApp.prototype, {
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
// server.json may still carry `true` — drop it so the server value is NEVER
// seeded into a device that didn't explicitly enable it (collection is handled
// separately via the statusLineTelemetry action, not this display flag).
delete appSettings.showPlanUsageLimits;
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
@@ -1942,7 +2196,8 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch('/api/subagent-window-states');
if (res.ok) {
states = await res.json();
const env = await res.json();
states = env?.success === true ? env.data : env;
// Also update localStorage
localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states));
}
@@ -2009,7 +2264,8 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch('/api/subagent-parents');
if (res.ok) {
mapData = await res.json();
const env = await res.json();
mapData = env?.success === true ? env.data : env;
// Update localStorage as cache
localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData));
}
+1369 -33
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -455,6 +455,12 @@ Object.assign(CodemanApp.prototype, {
svg.appendChild(line);
}
}
// Ultracode floating run windows → parent tab (additional layer, ultracode-windows.js).
// Drawn into the same SVG and same batched read/write pass; the tab-rect cache is shared.
if (typeof this._appendUltracodeConnectionLines === 'function') {
this._appendUltracodeConnectionLines(svg, rects);
}
},
// ═══════════════════════════════════════════════════════════════
@@ -975,6 +981,9 @@ Object.assign(CodemanApp.prototype, {
}
this.imagePopups.clear();
// Clean up ultracode floating run windows (re-seeded from data.workflowRuns on reconnect)
if (typeof this.removeAllUltracodeWindows === 'function') this.removeAllUltracodeWindows();
// Clear orphaned plan generation state
this.activePlanOrchestratorId = null;
this._planProgressHandler = null;
+395 -67
View File
@@ -12,6 +12,45 @@
* @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
*/
(function (global) {
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
// Grace window after a manual scroll-up gesture during which sticky-scroll is
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
}
function shouldSuppressTerminalQueryResponse(data) {
return isTerminalQueryResponse(data);
}
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
// theme, so default behavior is unchanged. Shared at module scope and exported on the
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
const CODEMAN_XTERM_THEMES = {
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
};
function currentXtermTheme() {
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
}
global.CodemanTerminalInput = {
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
})(window);
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Terminal Setup — xterm.js config and input handling
@@ -24,29 +63,7 @@ Object.assign(CodemanApp.prototype, {
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this.terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
@@ -61,6 +78,23 @@ Object.assign(CodemanApp.prototype, {
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
// SerializeAddon: lets us snapshot the xterm rendered state (viewport +
// scrollback + colors/attrs) when switching away from a tab and restore
// it on switch-back. Needed primarily for codex tabs — codex's TUI drops
// earlier conversation from its current frame, so replaying the server
// byte buffer on tab-switch shows only the latest (idle) frame. The
// snapshot captures what the user was actually looking at.
this._xtermSnapshots = new Map(); // Map<sessionId, serialized-string>
if (typeof SerializeAddon !== 'undefined') {
try {
this._serializeAddon = new SerializeAddon.SerializeAddon();
this.terminal.loadAddon(this._serializeAddon);
} catch (_e) {
/* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */
this._serializeAddon = null;
}
}
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
@@ -80,8 +114,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
// don't leak an escape sequence into the focused terminal on every tab switch.
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
return false;
}
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
@@ -164,7 +204,24 @@ Object.assign(CodemanApp.prototype, {
// whitespace) -- if so, xterm handled it and we should not double-send.
// Use a microtask to check after xterm's own handlers have run.
const data = e.data;
const pendingBefore = this._localEchoOverlay?.pendingText || '';
Promise.resolve().then(() => {
if (
this._lastTerminalData?.data === data &&
performance.now() - this._lastTerminalData.time < 100
) {
xtermTextarea.value = '';
return;
}
const pendingAfter = this._localEchoOverlay?.pendingText || '';
if (
this._localEchoEnabled &&
pendingAfter.length > pendingBefore.length &&
pendingAfter.endsWith(data)
) {
xtermTextarea.value = '';
return;
}
// If xterm cleared the textarea, it processed the input -- skip.
const val = xtermTextarea.value;
if (!val || (val.trim() === '' && data !== ' ')) return;
@@ -227,15 +284,17 @@ Object.assign(CodemanApp.prototype, {
}
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
if (MobileDetection.isTouchDevice()) {
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
}
// CJK IME input — textarea in index.html, just wire up send
this._cjkInput = null;
if (typeof CjkInput !== 'undefined') {
this._cjkInput = CjkInput.init({
send: (text) => {
if (this.activeSessionId) {
this._sendInputAsync(this.activeSessionId, text);
}
this._handleCjkInput(text);
},
});
}
@@ -265,6 +324,7 @@ Object.assign(CodemanApp.prototype, {
(ev) => {
ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
},
{ passive: false }
@@ -328,6 +388,7 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
ev.preventDefault();
didScroll = true;
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY; // positive = scroll down
@@ -338,12 +399,13 @@ Object.assign(CodemanApp.prototype, {
const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
pixelAccum -= lines * ch;
}
}
},
{ passive: true }
{ passive: false }
);
container.addEventListener(
@@ -357,7 +419,13 @@ Object.assign(CodemanApp.prototype, {
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
if (!didScroll && this.terminal) {
this.terminal.focus();
const cjkInput = document.getElementById('cjkInput');
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.focus();
} else {
this._syncMobileHelperTextareaToCursor();
this.terminal.focus();
}
}
},
{ passive: true }
@@ -382,6 +450,9 @@ Object.assign(CodemanApp.prototype, {
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
this._lastUserScrollUpAt = null;
// Handle resize with throttling for performance
this._resizeTimeout = null;
@@ -450,11 +521,30 @@ Object.assign(CodemanApp.prototype, {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }),
}).catch(() => {});
// Typed + WS-first like sendResize: the viewport type feeds resize
// arbitration (a phone rotating must not bypass a desktop claim),
// and a desktop window narrowing past the tablet breakpoint must
// send a typed WS frame so its stale desktop claim is released.
const viewportType =
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
? MobileDetection.getDeviceType()
: 'desktop';
let sentViaWs = false;
if (this._wsReady && this._wsSessionId === this.activeSessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: viewportType }));
sentViaWs = true;
} catch {
// Fall through to HTTP POST
}
}
if (!sentViaWs) {
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
}
}
}
// Update subagent connection lines and local echo at new dimensions
@@ -498,11 +588,15 @@ Object.assign(CodemanApp.prototype, {
// CJK input has focus — block xterm from sending to PTY
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
if (this.activeSessionId) {
// Filter out terminal query responses that xterm.js generates automatically.
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
// sent by tmux when attaching. Without this filter, they appear as typed text.
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
// Filter terminal query replies generated by xterm.js itself.
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
// into the foreground process as typed input (for example "0;276;0c").
if (
window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
) {
return;
}
this._lastTerminalData = { data, time: performance.now() };
// ── Local Echo Mode ──
// When enabled, keystrokes are buffered locally in the overlay for
@@ -770,7 +864,11 @@ Object.assign(CodemanApp.prototype, {
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
// Handles: tail -f /path, grep pattern /path, cat -n /path
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// ⚠ The arg group must stay linear-time: `(?:[^\s\/]*\s+)*` (empty-matchable
// token, unbounded) backtracks exponentially on lines with a trigger word
// followed by multi-space runs (e.g. wrapped heredoc/table output) — froze
// 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
const extPattern =
@@ -845,6 +943,9 @@ Object.assign(CodemanApp.prototype, {
this.loadTunnelStatus();
this.loadHistorySessions();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
this._updateCjkInputState?.();
},
hideWelcome() {
@@ -858,6 +959,9 @@ Object.assign(CodemanApp.prototype, {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('expanded');
}
// Entering a session — restore CJK textarea if the user has it enabled
// (activeSessionId is already set by selectSession before this call).
this._updateCjkInputState?.();
},
/**
@@ -868,7 +972,7 @@ Object.assign(CodemanApp.prototype, {
async _fetchHistorySessions() {
const res = await fetch('/api/history/sessions');
const data = await res.json();
const sessions = data.sessions || [];
const sessions = data.data?.sessions || [];
if (sessions.length === 0) return [];
const byProject = new Map();
@@ -1046,7 +1150,7 @@ Object.assign(CodemanApp.prototype, {
// Prefer already-loaded this.cases to avoid an extra request.
const casesPromise = Array.isArray(this.cases) && this.cases.length > 0
? Promise.resolve(this.cases)
: fetch('/api/cases').then((r) => (r.ok ? r.json() : [])).catch(() => []);
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
const [allSessions, cases] = await Promise.all([
this._fetchHistorySessions(30),
casesPromise,
@@ -1173,8 +1277,8 @@ Object.assign(CodemanApp.prototype, {
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
const res = await fetch(url);
const data = await res.json();
const sessions = data.sessions || [];
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset;
const sessions = data.data?.sessions || [];
state.total = typeof data.data?.total === 'number' ? data.data.total : sessions.length + offset;
if (offset === 0 && sessions.length === 0) {
const empty = document.createElement('div');
@@ -1261,7 +1365,7 @@ Object.assign(CodemanApp.prototype, {
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error);
const newSessionId = createData.session.id;
const newSessionId = createData.data.session.id;
// Start interactive
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
@@ -1291,6 +1395,22 @@ Object.assign(CodemanApp.prototype, {
return buffer.viewportY >= buffer.baseY - 2;
},
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
// lands back at the bottom clears the suppression immediately.
_noteTerminalUserScroll(lines) {
if (lines < 0) {
this._lastUserScrollUpAt = performance.now();
} else if (this.isTerminalAtBottom()) {
this._lastUserScrollUpAt = null;
}
},
_hasRecentUserScrollUp() {
if (typeof this._lastUserScrollUpAt !== 'number') return false;
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
},
batchTerminalWrite(data) {
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
@@ -1426,12 +1546,104 @@ Object.assign(CodemanApp.prototype, {
this._localEchoOverlay.clear();
this._localEchoEnabled = false;
} else {
// Claude Code: scan for ❯ prompt character
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
// Codex/Claude-style TUIs usually expose a ❯ prompt. During active
// redraws or compact mobile layouts that marker may not be present in
// the viewport, while xterm's cursor still marks the editable input
// position. Fall back to cursor coordinates so phone typing appears at
// the terminal cursor instead of disappearing into pending state.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 0,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
for (let row = terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
const idx = text.lastIndexOf('\u276f');
if (idx >= 0) return { row, col: idx + 2 };
}
return {
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
};
} catch {
return null;
}
},
});
}
}
},
_handleCjkInput(text) {
if (!this.activeSessionId) return;
const sessionId = this.activeSessionId;
const session = this.sessions.get(sessionId);
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
if (!useLocalEcho) {
this._sendInputAsync(sessionId, text);
return;
}
if (text === '\x7f') {
const source = this._localEchoOverlay.removeChar();
if (source === 'flushed') {
// Sync app-level flushed Maps (per-session state for tab switching),
// mirroring the onData backspace path — otherwise switching tabs away
// and back restores a stale, too-long flushed overlay.
const { count, text: flushedText } = this._localEchoOverlay.getFlushed();
if (this._flushedOffsets?.has(sessionId)) {
if (count === 0) {
this._flushedOffsets.delete(sessionId);
this._flushedTexts?.delete(sessionId);
} else {
this._flushedOffsets.set(sessionId, count);
this._flushedTexts?.set(sessionId, flushedText);
}
}
this._sendInputAsync(sessionId, text);
}
return;
}
if (/[\r\n]+$/.test(text)) {
const committed = text.replace(/[\r\n]+$/g, '');
if (committed) this._localEchoOverlay.appendText(committed);
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
return;
}
// Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on
// the composer) — forward to the PTY without touching overlay state,
// mirroring the onData path. Appending it to pending text would type raw
// ESC bytes into the prompt on the next Enter.
if (text.length > 1 && text.charCodeAt(0) === 27) {
this._sendInputAsync(sessionId, text);
return;
}
if (text.length === 1 && text.charCodeAt(0) < 32) {
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
this._sendInputAsync(sessionId, text);
return;
}
this._localEchoOverlay.appendText(text);
},
/**
* Flush pending writes to terminal, processing DEC 2026 sync markers.
* Strips markers and writes content atomically within a single frame.
@@ -1450,8 +1662,16 @@ Object.assign(CodemanApp.prototype, {
// Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
// Codex's TUI emits dense synchronized redraws during thinking/high-effort
// phases, so it gets a smaller first frame to keep per-frame xterm/WebGL
// stalls short; other modes keep the larger 64KB budget.
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
let deferred = false;
// If the user recently scrolled up, remember the viewport so we can restore
// it after the write — Codex status redraws would otherwise jump it.
const preserveViewportY =
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
@@ -1468,6 +1688,13 @@ Object.assign(CodemanApp.prototype, {
});
}
}
if (
preserveViewportY !== null &&
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
typeof this.terminal.scrollToLine === 'function'
) {
this.terminal.scrollToLine(preserveViewportY);
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0;
if (_dt > 100 || deferred)
@@ -1475,8 +1702,11 @@ Object.assign(CodemanApp.prototype, {
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
);
// Sticky scroll: if user was at bottom, keep them there after new output
if (this._wasAtBottomBeforeWrite) {
// Sticky scroll: if user was at bottom, keep them there after new output.
// Give manual scroll-up gestures a short grace window so high-frequency
// Codex status ticks do not snap the viewport back while the user is
// trying to inspect earlier output.
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
this.terminal.scrollToBottom();
}
@@ -1594,6 +1824,38 @@ Object.assign(CodemanApp.prototype, {
}
},
scrollToLastNonEmptyLine() {
if (!this.terminal?.buffer?.active) {
this.terminal?.scrollToBottom?.();
return;
}
const buffer = this.terminal.buffer.active;
const totalLines = buffer.baseY + buffer.length;
let lastNonEmptyLine = -1;
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
const line = buffer.getLine(lineIndex);
if (line?.translateToString(true).trim()) {
lastNonEmptyLine = lineIndex;
break;
}
}
if (lastNonEmptyLine >= 0 && typeof this.terminal.scrollToLine === 'function') {
let targetLine = Math.max(0, lastNonEmptyLine - this.terminal.rows + 2);
const maxTargetLine = Math.max(0, lastNonEmptyLine);
while (targetLine < maxTargetLine) {
const line = buffer.getLine(targetLine);
if (line?.translateToString(true).trim()) break;
targetLine++;
}
this.terminal.scrollToLine(targetLine);
} else {
this.terminal.scrollToBottom();
}
},
/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses _safeYield to spread work across frames; falls back to setTimeout
@@ -1602,22 +1864,19 @@ Object.assign(CodemanApp.prototype, {
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
resolve();
return;
}
// Block live SSE writes during buffer load to prevent interleaving
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
// Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
@@ -1625,15 +1884,14 @@ Object.assign(CodemanApp.prototype, {
const finish = () => {
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
}
resolve();
};
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer);
finish();
this.terminal.write(cleanBuffer, finish);
return;
}
@@ -1691,9 +1949,23 @@ Object.assign(CodemanApp.prototype, {
* (especially Ink cursor-up redraws), corrupting the terminal display.
* After unblocking, new SSE/WS events deliver subsequent output normally.
*/
_finishBufferLoad() {
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
return loadOwner;
},
_finishBufferLoad(owner) {
if (owner !== undefined && this._bufferLoadOwner !== owner) {
return false;
}
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
return true;
},
// ═══════════════════════════════════════════════════════════════
@@ -1765,6 +2037,23 @@ Object.assign(CodemanApp.prototype, {
}
},
_syncMobileHelperTextareaToCursor() {
if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return;
try {
const xtermEl = this.terminal.element;
const cursor = this.terminal.element.querySelector('.xterm-cursor');
const screen = this.terminal.element.querySelector('.xterm-screen');
if (!(xtermEl instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement)) return;
const cursorRect = cursor.getBoundingClientRect();
const screenRect = screen.getBoundingClientRect();
if (!cursorRect.width && !cursorRect.height) return;
const left = Math.max(0, Math.round(cursorRect.left - screenRect.left));
const top = Math.max(0, Math.round(cursorRect.top - screenRect.top));
xtermEl.style.setProperty('--xterm-helper-left', `${left}px`);
xtermEl.style.setProperty('--xterm-helper-top', `${top}px`);
} catch {}
},
increaseFontSize() {
const current = this.terminal.options.fontSize || 14;
this.setFontSize(Math.min(current + 2, 24));
@@ -1814,23 +2103,38 @@ Object.assign(CodemanApp.prototype, {
/**
* Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId
* @param {{ forceHttp?: boolean }} [options]
* @returns {Promise<void>}
*/
async sendResize(sessionId) {
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
if (!dims) return false;
// Did the dimensions actually change since the last resize we sent? Callers
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
// real SIGWINCH was triggered — switching tabs at the same browser size is
// a no-op on the server and needs no redraw grace.
const prev = this._lastResizeDims;
const changed = !prev || prev.cols !== dims.cols || prev.rows !== dims.rows;
// Update _lastResizeDims so the throttledResize handler won't redundantly
// clear the terminal for the same dimensions (which would blank the screen
// without a subsequent Ink redraw to repaint it).
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
const viewportType =
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
? MobileDetection.getDeviceType()
: window.innerWidth < 430
? 'mobile'
: window.innerWidth < 768
? 'tablet'
: 'desktop';
// Fast path: WebSocket resize
if (this._wsReady && this._wsSessionId === sessionId) {
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
return;
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
return changed;
} catch {
// Fall through to HTTP POST
}
@@ -1838,8 +2142,9 @@ Object.assign(CodemanApp.prototype, {
await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims),
body: JSON.stringify({ ...dims, viewportType }),
});
return changed;
},
/**
@@ -1883,4 +2188,27 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = value || 'No directory';
}, 100);
},
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
if (this.terminal) {
this.terminal.options.theme = theme;
try {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
}
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
} catch {}
}
}
}
},
});
+321
View File
@@ -0,0 +1,321 @@
/**
* @fileoverview Ultracode / Workflow run visualization — master-detail dock panel.
*
* Mirrors Claude Code's "working agents" TUI: LEFT pane = runs and their phases
* (selectable "tasks"), RIGHT pane = the selected run's agents with model, live
* state, TOKENS burned, and TOOL CALLS. Opt-in via the `showUltracodeAgents`
* setting; the launcher button + panel are hidden until enabled.
*
* Data: run SUMMARIES arrive via getLightState (`data.workflowRuns`) and the
* `workflow:run_*` SSE events (LEFT list). The full run (with agents[]) is fetched
* per-run from GET /api/workflows/:runId when a run is selected (RIGHT pane).
*
* Standalone: reads only the workflow-run endpoints; never touches subagent state.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @loadorder 11.5 (after panels-ui.js, before session-ui.js)
*/
/* global CodemanApp, SSE_EVENTS, escapeHtml */
Object.assign(CodemanApp.prototype, {
/** Ensure workflow state maps exist (lazy — constructor also seeds them). */
_ensureWorkflowState() {
if (!this.workflowRuns) this.workflowRuns = new Map(); // runId -> summary
if (!this.workflowRunDetails) this.workflowRunDetails = new Map(); // runId -> full run (with agents)
if (this.activeWorkflowRunId === undefined) this.activeWorkflowRunId = null;
if (this.activeWorkflowPhaseIndex === undefined) this.activeWorkflowPhaseIndex = null;
},
/** Seed the LEFT list from a getLightState snapshot (array of run summaries). */
seedWorkflowRuns(summaries) {
this._ensureWorkflowState();
this.workflowRuns.clear();
(summaries || []).forEach((s) => this.workflowRuns.set(s.runId, s));
// Restore floating windows for runs that are still active & recent (additional layer).
if (typeof this._syncUltracodeFloatingWindow === 'function') {
(summaries || []).forEach((s) => this._syncUltracodeFloatingWindow(s, { fromSeed: true }));
}
this.renderUltracodeAgentsPanel();
},
// ----- SSE handlers (wired in app.js _SSE_HANDLER_MAP) -----
_onWorkflowRunDiscovered(data) {
this._upsertWorkflowRun(data);
},
_onWorkflowRunUpdated(data) {
this._upsertWorkflowRun(data);
},
_onWorkflowRunRemoved(data) {
this._ensureWorkflowState();
if (!data || !data.runId) return;
this.workflowRuns.delete(data.runId);
this.workflowRunDetails.delete(data.runId);
if (this.activeWorkflowRunId === data.runId) this.activeWorkflowRunId = null;
// Retire the floating run window too (additional layer — ultracode-windows.js).
if (typeof this.closeUltracodeWindow === 'function') this.closeUltracodeWindow(data.runId, false);
this.renderUltracodeAgentsPanel();
},
_upsertWorkflowRun(summary) {
this._ensureWorkflowState();
if (!summary || !summary.runId) return;
this.workflowRuns.set(summary.runId, summary);
// If the live-updating run is the one open in the detail pane, refresh its agents.
if (this.activeWorkflowRunId === summary.runId) {
this._fetchWorkflowRunDetail(summary.runId);
}
// Auto-pop / refresh the floating run window for active runs (additional layer).
if (typeof this._syncUltracodeFloatingWindow === 'function') this._syncUltracodeFloatingWindow(summary);
this.renderUltracodeAgentsPanel();
},
// ----- Panel open/close -----
toggleUltracodeAgentsPanel() {
const panel = document.getElementById('ultracodeAgentsPanel');
if (!panel) return;
panel.classList.remove('hidden');
panel.classList.toggle('open');
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
},
closeUltracodeAgentsPanel() {
// The X must FULLY hide the panel. Removing only `open` drops it to the
// collapsed peek state (header strip still visible), so add `hidden`
// (display:none) too — mirrors closeSubagentsPanel. Not the showUltracodeAgents
// setting: that also gates the watcher + floating windows; the launcher reopens.
const panel = document.getElementById('ultracodeAgentsPanel');
if (panel) {
panel.classList.remove('open');
panel.classList.add('hidden');
}
},
// ----- Selection -----
selectWorkflowRun(runId) {
this._ensureWorkflowState();
this.activeWorkflowRunId = runId;
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
this._fetchWorkflowRunDetail(runId);
this.renderUltracodeAgentsPanel();
},
selectWorkflowPhase(phaseIndex) {
this._ensureWorkflowState();
// phaseIndex null => show all phases
this.activeWorkflowPhaseIndex = phaseIndex === null || phaseIndex === undefined ? null : Number(phaseIndex);
this._renderUltracodeDetail();
},
async _fetchWorkflowRunDetail(runId) {
try {
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
const env = await res.json();
const run = env && env.success ? env.data : null;
if (run) {
this.workflowRunDetails.set(runId, run);
if (this.activeWorkflowRunId === runId) this._renderUltracodeDetail();
// Refresh the floating window (if one is open for this run) with the fetched agents[].
if (this.ultracodeWindows && this.ultracodeWindows.has(runId)) this.renderUltracodeWindowContent(runId);
}
} catch {
/* transient — next update retries */
}
},
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
async openWorkflowAgentTranscript(agentId) {
if (!agentId) return;
let data = null;
try {
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
data = await res.json();
} catch {
data = null;
}
const ok = data && data.success && data.data;
const formatted = ok ? data.data.formatted : null;
const entryCount = ok ? data.data.entryCount || 0 : 0;
if (!formatted || !entryCount) {
alert(
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
);
return;
}
const win = window.open('', '_blank', 'width=860,height=640');
if (!win) return; // popup blocked
win.document.write(
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
);
win.document.close();
},
// ----- Render (debounced) -----
renderUltracodeAgentsPanel() {
clearTimeout(this._ultracodeRenderTimer);
this._ultracodeRenderTimer = setTimeout(() => this._renderUltracodeAgentsPanelImmediate(), 150);
},
_renderUltracodeAgentsPanelImmediate() {
this._ensureWorkflowState();
const panel = document.getElementById('ultracodeAgentsPanel');
if (!panel) return;
const badge = document.getElementById('ultracodeCountBadge');
if (badge) badge.textContent = this.workflowRuns.size ? String(this.workflowRuns.size) : '';
this._renderUltracodeRunList();
this._renderUltracodeDetail();
},
_renderUltracodeRunList() {
const list = document.getElementById('ultracodeRunList');
if (!list) return;
const runs = Array.from(this.workflowRuns.values()).sort(
(a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0)
);
if (!runs.length) {
list.innerHTML = '<div class="subagent-empty">No ultracode runs detected</div>';
return;
}
list.innerHTML = runs.map((r) => this._workflowRunRowHtml(r)).join('');
},
_workflowRunRowHtml(r) {
const active = r.runId === this.activeWorkflowRunId;
const name = escapeHtml(r.workflowName || r.summary || r.runId);
const status = String(r.status || '');
const statusCls = this._workflowStatusClass(status);
const stats = `${r.agentCount ?? 0} agents · ${this._fmtNum(r.totalTokens)} tok · ${r.totalToolCalls ?? 0} tools`;
let phasesHtml = '';
if (active && Array.isArray(r.phases) && r.phases.length) {
const allActive = this.activeWorkflowPhaseIndex === null ? ' selected' : '';
const chips = [
`<div class="ultracode-phase-chip${allActive}" onclick="event.stopPropagation();app.selectWorkflowPhase(null)">All</div>`,
];
r.phases.forEach((p, i) => {
const sel = this.activeWorkflowPhaseIndex === i + 1 ? ' selected' : '';
chips.push(
`<div class="ultracode-phase-chip${sel}" title="${escapeHtml(p.detail || '')}" onclick="event.stopPropagation();app.selectWorkflowPhase(${i + 1})">${escapeHtml(p.title || 'Phase ' + (i + 1))}</div>`
);
});
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
}
return (
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
phasesHtml +
`</div>`
);
},
_renderUltracodeDetail() {
const detail = document.getElementById('ultracodeAgentGrid');
if (!detail) return;
const runId = this.activeWorkflowRunId;
if (!runId) {
detail.innerHTML = '<div class="subagent-empty">Select a run to view its agents</div>';
return;
}
const run = this.workflowRunDetails.get(runId);
if (!run) {
detail.innerHTML = '<div class="subagent-empty">Loading agents…</div>';
return;
}
const phases = Array.isArray(run.phases) ? run.phases : [];
let agents = Array.isArray(run.agents) ? run.agents : [];
if (this.activeWorkflowPhaseIndex !== null) {
agents = agents.filter((a) => a.phaseIndex === this.activeWorkflowPhaseIndex);
}
if (!agents.length) {
detail.innerHTML = '<div class="subagent-empty">No agents in this view</div>';
return;
}
// Group agents by phaseIndex, in phase order.
const groups = new Map();
agents.forEach((a) => {
const key = a.phaseIndex || 0;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(a);
});
const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b);
const html = orderedKeys
.map((key) => {
const group = groups.get(key);
const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`;
const tok = group.reduce((s, a) => s + (a.tokens || 0), 0);
const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0);
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
})
.join('');
detail.innerHTML = html;
},
_workflowAgentCardHtml(a) {
const state = String(a.state || 'start');
const stateCls = this._workflowAgentStateClass(state);
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
const model = this._modelShort(a.model);
const tokens = a.tokens === undefined ? '—' : this._fmtNum(a.tokens);
const tools = a.toolCalls === undefined ? '—' : String(a.toolCalls);
let secondary = '';
if (state === 'done' && a.resultPreview) {
secondary = escapeHtml(a.resultPreview);
} else if (a.lastToolName) {
secondary = escapeHtml(a.lastToolName + (a.lastToolSummary ? ' · ' + a.lastToolSummary : ''));
}
// Phase 4: cards with an agentId open the live transcript (the agentId is byte-identical
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
// no agentId yet, so they stay non-clickable.
const clickable = !!a.agentId;
const cardAttrs = clickable
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
: ` class="ultracode-agent-card"`;
return (
`<div${cardAttrs}>` +
`<div class="ultracode-agent-top">` +
`<span class="ultracode-agent-label">${escapeHtml(a.label || 'agent')}</span>` +
`<span class="ultracode-agent-state ${stateCls}">${escapeHtml(stateLabel)}</span>` +
`</div>` +
`<div class="ultracode-agent-meta">` +
`<span class="ultracode-chip" title="model">${escapeHtml(model)}</span>` +
`<span class="ultracode-chip ultracode-chip-tok" title="tokens burned">${tokens} tok</span>` +
`<span class="ultracode-chip ultracode-chip-tool" title="tool calls">${tools} tools</span>` +
`</div>` +
(secondary ? `<div class="ultracode-agent-sub">${secondary}</div>` : '') +
`</div>`
);
},
// ----- helpers -----
_workflowStatusClass(status) {
if (status === 'completed') return 'completed';
if (status === 'running') return 'active';
if (status === 'killed' || status === 'failed') return 'failed';
return '';
},
_workflowAgentStateClass(state) {
if (state === 'done') return 'completed';
if (state === 'progress') return 'active';
return 'idle'; // start / queued
},
_modelShort(model) {
if (!model) return '';
return String(model)
.replace(/^claude-/, '')
.replace(/-\d{8}$/, '');
},
_fmtNum(n) {
if (n === undefined || n === null) return '0';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
},
});
+384
View File
@@ -0,0 +1,384 @@
/**
* @fileoverview Ultracode floating run windows — auto-popping draggable windows
* with a connector line to the originating session tab.
*
* This is the "floating thing" companion to the docked master-detail panel in
* `ultracode-panel.js` (the dock panel stays — these windows are ADDITIONAL).
* When the `ultracodeFloatingWindows` setting is on (a DEDICATED toggle, separate
* from the dock panel's `showUltracodeAgents` — see `_ultracodeFloatingEnabled`),
* a small floating window pops up
* for each ACTIVE ultracode/Workflow run (status not completed/killed/failed),
* mirroring the live agent grid, and is connected by a glowing line to the
* Codeman tab whose `claudeSessionId` matches the run's `sessionUuid` — the same
* line idiom subagent windows use. The window auto-closes a few seconds after
* its run finishes; an explicitly-closed run is remembered and never re-pops.
*
* Reuses, rather than duplicates:
* - `makeWindowDraggable` + the shared `#connectionLines` SVG (subagent-windows.js)
* - `_workflowAgentCardHtml`, `_fmtNum`, `_workflowStatusClass`, `_fetchWorkflowRunDetail`,
* and the `workflowRuns` / `workflowRunDetails` maps (ultracode-panel.js)
*
* The connector-line draw is appended to the shared SVG from inside
* `_updateConnectionLinesImmediate` (subagent-windows.js calls
* `_appendUltracodeConnectionLines` at the end of its render pass), so both the
* subagent and ultracode lines live in one batched read→write reflow pass.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (makeWindowDraggable, updateConnectionLines, #connectionLines)
* @dependency ultracode-panel.js (workflowRuns/workflowRunDetails, _workflowAgentCardHtml, _fmtNum)
* @loadorder 15.5 (after subagent-windows.js — needs makeWindowDraggable at runtime)
*/
/* global CodemanApp, escapeHtml */
Object.assign(CodemanApp.prototype, {
/** Lazily seed the floating-window state maps (constructor also seeds them). */
_ensureUltracodeWindowState() {
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
},
/** Floating windows have their own opt-in (default OFF), independent of the dock panel. */
_ultracodeFloatingEnabled() {
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
return !!(settings && settings.ultracodeFloatingWindows);
},
/** A run is "working" until it reaches a terminal status. Mid-run status is absent. */
_isWorkflowRunActive(run) {
const s = String((run && run.status) || '');
return !(s === 'completed' || s === 'killed' || s === 'failed');
},
/**
* Resolve which Codeman tab a run belongs to: the session whose
* `claudeSessionId` equals the run's `sessionUuid` (the path segment the watcher
* captured). Falls back to the active session so the line still lands somewhere.
*/
_resolveUltracodeParentSession(run) {
const uuid = run && run.sessionUuid;
if (uuid && this.sessions) {
for (const [sessionId, session] of this.sessions) {
if (session && session.claudeSessionId === uuid) return sessionId;
}
}
if (this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
return this.activeSessionId;
}
return null;
},
/**
* Auto-pop driver — called for every run discovered/updated and on reconnect seed.
* Creates a floating window for active runs, refreshes existing ones, and schedules
* an auto-close once a run finishes.
*/
_syncUltracodeFloatingWindow(run, opts) {
this._ensureUltracodeWindowState();
if (!run || !run.runId) return;
if (!this._ultracodeFloatingEnabled()) return;
const runId = run.runId;
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
const active = this._isWorkflowRunActive(run);
const existing = this.ultracodeWindows.get(runId);
if (active) {
// Run is alive — cancel any pending auto-close.
const pending = this.ultracodeWindowCloseTimers.get(runId);
if (pending) {
clearTimeout(pending);
this.ultracodeWindowCloseTimers.delete(runId);
}
if (existing) {
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId); // refresh agents[]; re-renders window on land
} else {
// On a reconnect snapshot, only restore windows for genuinely recent runs so
// a backlog of stale undefined-status runs doesn't carpet the screen.
if (opts && opts.fromSeed) {
const FLOAT_SEED_MAX_AGE_MS = 5 * 60 * 1000;
const age = Date.now() - (run.lastActivityAt || 0);
if (!(run.lastActivityAt && age < FLOAT_SEED_MAX_AGE_MS)) return;
}
this.createUltracodeWindow(run);
}
} else if (existing) {
// Finished — refresh to the final state (status + final agent states), show it
// briefly, then retire the floating window.
this._fetchWorkflowRunDetail(runId);
this.renderUltracodeWindowContent(runId);
if (!this.ultracodeWindowCloseTimers.has(runId)) {
const FLOAT_FINISH_GRACE_MS = 8000;
const timer = setTimeout(() => {
this.ultracodeWindowCloseTimers.delete(runId);
this.closeUltracodeWindow(runId, false);
}, FLOAT_FINISH_GRACE_MS);
this.ultracodeWindowCloseTimers.set(runId, timer);
}
}
},
/** Build and mount a floating window for a run, positioned near its parent tab. */
createUltracodeWindow(run) {
this._ensureUltracodeWindowState();
const runId = run.runId;
if (this.ultracodeWindows.has(runId)) return;
const parentSessionId = this._resolveUltracodeParentSession(run);
const titleText = run.workflowName || run.summary || runId;
const win = document.createElement('div');
win.className = 'ultracode-window spawning';
win.id = `ultracode-window-${runId}`;
win.style.zIndex = ++this.ultracodeWindowZIndex;
win.innerHTML = `
<div class="ultracode-window-header">
<div class="ultracode-window-title" title="${escapeHtml(titleText)}">
<span class="icon">🧬</span>
<span class="uw-name">${escapeHtml(titleText)}</span>
<span class="uw-status"></span>
</div>
<div class="ultracode-window-actions">
<button class="uw-min" type="button" title="Collapse">─</button>
<button class="uw-close" type="button" title="Close">&times;</button>
</div>
</div>
<div class="ultracode-window-body" id="ultracode-window-body-${runId}">
<div class="subagent-empty">Loading agents…</div>
</div>
`;
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
if (parentTab) {
const r = parentTab.getBoundingClientRect();
const left = Math.max(8, Math.min(r.left, window.innerWidth - 392));
win.style.left = `${left}px`;
win.style.top = `${r.bottom + 14}px`;
} else {
const n = this.ultracodeWindows.size;
win.style.left = `${24 + n * 26}px`;
win.style.top = `${96 + n * 26}px`;
}
document.body.appendChild(win);
// Drop the spawn class on the next frame so the transition runs.
requestAnimationFrame(() => win.classList.remove('spawning'));
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
win.querySelector('.uw-min').addEventListener('click', (e) => {
e.stopPropagation();
this.toggleUltracodeWindowCollapse(runId);
});
win.querySelector('.uw-close').addEventListener('click', (e) => {
e.stopPropagation();
this.closeUltracodeWindow(runId, true);
});
const nameEl = win.querySelector('.uw-name');
if (parentSessionId) {
nameEl.style.cursor = 'pointer';
nameEl.title = 'Go to session';
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
}
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
this.updateConnectionLines();
},
/** Collapse/expand the window to header-only (line stays connected). */
toggleUltracodeWindowCollapse(runId) {
const data = this.ultracodeWindows.get(runId);
if (!data) return;
data.collapsed = !data.collapsed;
data.element.classList.toggle('collapsed', data.collapsed);
this.updateConnectionLines();
},
/** Remove a floating window. `userInitiated` records a dismissal so it won't re-pop. */
closeUltracodeWindow(runId, userInitiated) {
this._ensureUltracodeWindowState();
const pending = this.ultracodeWindowCloseTimers.get(runId);
if (pending) {
clearTimeout(pending);
this.ultracodeWindowCloseTimers.delete(runId);
}
const data = this.ultracodeWindows.get(runId);
if (userInitiated) this.ultracodeWindowsClosed.add(runId);
if (!data) return;
this._teardownUltracodeDrag(data.dragListeners);
data.element.remove();
this.ultracodeWindows.delete(runId);
this.updateConnectionLines();
},
/** Detach the document-level drag listeners returned by makeWindowDraggable. */
_teardownUltracodeDrag(dl) {
if (!dl) return;
document.removeEventListener('mousemove', dl.move);
document.removeEventListener('mouseup', dl.up);
if (dl.touchMove) {
document.removeEventListener('touchmove', dl.touchMove);
document.removeEventListener('touchend', dl.up);
document.removeEventListener('touchcancel', dl.up);
}
if (dl.handle) {
dl.handle.removeEventListener('mousedown', dl.handleMouseDown);
dl.handle.removeEventListener('touchstart', dl.handleTouchStart);
}
},
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
removeAllUltracodeWindows() {
this._ensureUltracodeWindowState();
const had = this.ultracodeWindows.size > 0;
for (const [, data] of this.ultracodeWindows) {
this._teardownUltracodeDrag(data.dragListeners);
if (data.element) data.element.remove();
}
this.ultracodeWindows.clear();
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
this.ultracodeWindowCloseTimers.clear();
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
if (had) this.updateConnectionLines();
},
/** When the feature is toggled on, pop windows for any currently-active runs. */
syncAllUltracodeFloatingWindows() {
this._ensureUltracodeWindowState();
if (!this._ultracodeFloatingEnabled()) {
this.removeAllUltracodeWindows();
return;
}
if (!this.workflowRuns) return;
for (const run of this.workflowRuns.values()) {
this._syncUltracodeFloatingWindow(run, { fromSeed: true });
}
},
/** Refresh a floating window's header + body from the latest summary/detail. */
renderUltracodeWindowContent(runId) {
const data = this.ultracodeWindows.get(runId);
if (!data) return;
const summary = this.workflowRuns && this.workflowRuns.get(runId);
const detail = this.workflowRunDetails && this.workflowRunDetails.get(runId);
// Summary is the freshest run-level info (every SSE tick); detail supplies agents[]
// but is fetched less often. Merge so a completed summary isn't masked by stale detail.
const run = summary && detail ? { ...detail, ...summary, agents: detail.agents } : detail || summary;
if (!run) return;
const nameEl = data.element.querySelector('.uw-name');
if (nameEl) nameEl.textContent = run.workflowName || run.summary || runId;
const statusEl = data.element.querySelector('.uw-status');
if (statusEl) {
const finished = !this._isWorkflowRunActive(run);
const label = run.status ? String(run.status) : finished ? '—' : 'running';
const clsKey = run.status ? run.status : finished ? '' : 'running';
statusEl.textContent = label;
statusEl.className = 'uw-status ultracode-status ' + this._workflowStatusClass(clsKey);
}
const body = data.element.querySelector('.ultracode-window-body');
if (body) body.innerHTML = this._ultracodeWindowBodyHtml(run);
},
/** Compact body: a stats line + agent cards grouped by phase (reuses panel helpers). */
_ultracodeWindowBodyHtml(run) {
const phases = Array.isArray(run.phases) ? run.phases : [];
const agents = Array.isArray(run.agents) ? run.agents : null;
const agentCount = run.agentCount ?? (agents ? agents.length : 0);
const head = `<div class="uw-summary">${this._fmtNum(run.totalTokens)} tok · ${run.totalToolCalls ?? 0} tools · ${agentCount} agents</div>`;
if (!agents) {
// Summary-only (detail not fetched yet): show phase chips as a teaser.
if (phases.length) {
const chips = phases
.map(
(p) =>
`<span class="ultracode-phase-chip" title="${escapeHtml(p.detail || '')}">${escapeHtml(p.title || '')}</span>`
)
.join('');
return (
head + `<div class="ultracode-phase-list">${chips}</div><div class="subagent-empty">Loading agents…</div>`
);
}
return head + '<div class="subagent-empty">Loading agents…</div>';
}
if (!agents.length) return head + '<div class="subagent-empty">No agents yet</div>';
const groups = new Map();
agents.forEach((a) => {
const key = a.phaseIndex || 0;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(a);
});
const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b);
const grid = orderedKeys
.map((key) => {
const group = groups.get(key);
const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`;
const tok = group.reduce((s, a) => s + (a.tokens || 0), 0);
const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0);
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
})
.join('');
return head + grid;
},
/**
* Append ultracode-window → parent-tab connector lines into the shared SVG.
* Invoked at the tail of `_updateConnectionLinesImmediate` (subagent-windows.js),
* so it shares that pass's batched read/write discipline. `rects` is the tab-rect
* cache already populated for subagent lines — reuse it, fill any gaps.
*/
_appendUltracodeConnectionLines(svg, rects) {
this._ensureUltracodeWindowState();
if (!svg || !this.ultracodeWindows.size) return;
if (!rects) rects = new Map();
// PHASE 1: layout reads (resolve parents, batch getBoundingClientRect).
const winList = [];
for (const [runId, data] of this.ultracodeWindows) {
if (!data.element) continue;
if (!data.parentSessionId) {
const summary = this.workflowRuns && this.workflowRuns.get(runId);
if (summary) data.parentSessionId = this._resolveUltracodeParentSession(summary);
}
const parentSessionId = data.parentSessionId;
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
}
// PHASE 2: writes (curve from tab bottom-center to window top-center).
for (const { runId, parentSessionId, winRect } of winList) {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) continue;
const x1 = tabRect.left + tabRect.width / 2;
const y1 = tabRect.bottom;
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (y1 + y2) / 2;
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection');
line.setAttribute('data-run-id', runId);
line.setAttribute('data-parent-tab', parentSessionId);
svg.appendChild(line);
}
},
});
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -262,7 +262,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) {
return { success: true, exists: false, content: null, todos: [] };
return { exists: false, content: null, todos: [] };
}
try {
@@ -339,7 +339,6 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const stats = { total: todos.length, pending, inProgress, completed };
return {
success: true,
exists: true,
content,
todos,
+3 -2
View File
@@ -6,19 +6,20 @@
import { FastifyInstance } from 'fastify';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
import { createErrorResponse, ApiErrorCode } from '../../types.js';
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
app.post('/api/clipboard', async (req) => {
const body = req.body as { text?: string; sessionId?: string };
const text = body?.text;
if (typeof text !== 'string' || text.length === 0) {
return { success: false, error: 'Missing or empty "text" field' };
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing or empty "text" field');
}
ctx.broadcast(SseEvent.ClipboardWrite, {
text,
sessionId: body.sessionId ?? null,
timestamp: Date.now(),
});
return { success: true };
return {};
});
}
+657 -48
View File
@@ -3,16 +3,370 @@
* Provides directory listing, file content preview, raw file serving, and tail streaming.
*/
import { FastifyInstance } from 'fastify';
import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, join } from 'node:path';
import { homedir } from 'node:os';
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
AttachmentRegistrationError,
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
isSupportedAttachmentExtension,
registerExternalAttachment,
type AttachmentRecord,
} from '../../attachment-registry.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
};
function sanitizeDownloadName(fileName: string): string {
return fileName.replace(/["\\\r\n]/g, '_');
}
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
const headers = reply.getHeaders();
reply.hijack();
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) {
reply.raw.setHeader(name, value);
}
}
content.on('error', (err) => {
if (reply.raw.headersSent) {
reply.raw.destroy(err);
return;
}
reply.raw.statusCode = 500;
reply.raw.end('Failed to read file');
});
content.pipe(reply.raw);
}
async function serveRawFile(
reply: FastifyReply,
resolvedPath: string,
fileName: string,
extension: string,
download?: boolean
): Promise<void> {
const stat = await fs.stat(resolvedPath);
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
)
);
return;
}
const content = createReadStream(resolvedPath);
const safeName = sanitizeDownloadName(fileName);
if (download || extension === 'svg') {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
return;
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
}
function getAttachmentOr404(
reply: FastifyReply,
sessionId: string,
attachmentId: string
): AttachmentRecord | undefined {
const record = attachmentRegistry.get(sessionId, attachmentId);
if (!record) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
return undefined;
}
return record;
}
/**
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
* blocked by the active attachment-guard policy, even though registration
* already blocks them. Guards against records that predate the guard or were
* crafted to point at a sensitive file. Resolves symlinks before the check so a
* record pointing at a symlink that now resolves to a sensitive target is also
* caught; if the path can't be resolved (deleted/unreadable) the check still
* runs on the stored path. When workspace confinement is enabled it additionally
* rejects any record outside the session workspace. Returns true (and sends a
* 403) when blocked.
*/
async function resolveServableAttachmentPath(
reply: FastifyReply,
record: AttachmentRecord,
sessionWorkingDir?: string
): Promise<string | null> {
let pathToCheck = record.filePath;
let resolved = false;
try {
pathToCheck = realpathSync(record.filePath);
resolved = true;
} catch {
// Fall back to the stored (already realpath-resolved at registration) path.
}
const guard = await loadAttachmentGuardConfig();
const blocked =
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
if (blocked) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return null;
}
// Serve the freshly-resolved path, not the stored one: if a path component
// became a symlink after registration, the guard checked the resolved target
// but streaming record.filePath would follow the symlink to a swapped file.
return resolved ? pathToCheck : record.filePath;
}
/**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers
* redirect those to the raw route instead.
*/
async function serveConvertedPreview(
reply: FastifyReply,
resolvedPath: string,
fileName: string,
extension: string
): Promise<void> {
if (extension !== 'docx' && extension !== 'pptx') {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
return;
}
try {
const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
if (!previewPath) {
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
return;
}
const content = await fs.readFile(previewPath);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
reply.header('Cache-Control', 'no-cache');
reply.header('Content-Length', content.length);
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(content);
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
}
}
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise<void> {
const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
if (!thumbnail) {
reply.code(204).send();
return;
}
reply.header('Content-Type', thumbnail.contentType);
reply.header('Cache-Control', 'no-cache');
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(thumbnail.content);
}
/**
* Resolve a session's working dir from the live session, falling back to the
* persisted record so preview/thumbnail requests keep working for a session
* that has since detached. Sends a 404 and returns undefined when unknown.
*/
function getKnownSessionWorkingDir(
ctx: SessionPort & ConfigPort,
sessionId: string,
reply: FastifyReply
): string | undefined {
const liveSession = ctx.sessions.get(sessionId);
if (liveSession) return liveSession.workingDir;
const stored = ctx.store.getSession(sessionId);
if (stored) return stored.workingDir;
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
return undefined;
}
// Persisted sessions carry the private (externalPath-bearing) history under a
// `__attachmentHistory` key so the list route can re-register external files.
type StoredSessionWithPrivateAttachmentHistory = SessionState & {
__attachmentHistory?: SessionAttachmentHistoryItem[];
};
type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPath'> & {
missing: boolean;
rawUrl?: string;
url?: string;
previewUrl?: string;
thumbnailUrl?: string;
downloadUrl?: string;
attachmentId?: string;
};
function appendDownloadFlag(url: string): string {
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
}
function getSessionAttachmentHistory(
ctx: SessionPort & ConfigPort,
sessionId: string
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
const liveSession = ctx.sessions.get(sessionId);
if (liveSession) {
return {
workingDir: liveSession.workingDir,
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
};
}
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
if (!stored) return undefined;
return {
workingDir: stored.workingDir,
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
};
}
// History item for a file detected inside the workspace: re-stat for live
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
async function buildDetectedAttachmentRouteItem(
sessionId: string,
workingDir: string,
item: SessionAttachmentHistoryItem
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.relativePath) {
return { ...safe, missing: true };
}
const validated = validateSessionFilePath(workingDir, item.relativePath);
if (!validated) {
return { ...safe, missing: true };
}
let size = item.size;
let mtimeMs = item.mtimeMs;
try {
const stat = await fs.stat(validated.resolvedPath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
return { ...safe, missing: true };
}
const encodedPath = encodeURIComponent(item.relativePath);
const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
const previewUrl =
item.extension === 'docx' || item.extension === 'pptx'
? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}`
: rawUrl;
const thumbnailUrl = isSupportedAttachmentExtension(item.extension)
? buildFileThumbnailRoute(sessionId, item.relativePath)
: undefined;
return {
...safe,
size,
mtimeMs,
missing: false,
rawUrl,
url: rawUrl,
previewUrl,
thumbnailUrl,
downloadUrl: appendDownloadFlag(rawUrl),
};
}
// History item for an explicitly published external file: re-register it to mint
// a fresh id + by-id routes (the guard runs again), or mark it missing.
async function buildExternalAttachmentRouteItem(
sessionId: string,
item: SessionAttachmentHistoryItem,
sessionWorkingDir?: string
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.externalPath) {
return { ...safe, missing: true };
}
try {
const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
return {
...safe,
fileName: event.fileName,
extension: event.extension,
attachmentType: event.attachmentType,
size: event.size,
missing: false,
attachmentId: event.attachmentId,
rawUrl: event.rawUrl,
url: event.rawUrl,
previewUrl: event.previewUrl,
thumbnailUrl: event.thumbnailUrl,
downloadUrl: appendDownloadFlag(event.rawUrl),
};
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
return { ...safe, missing: true };
}
throw err;
}
}
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
@@ -157,49 +511,73 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
try {
const stat = await fs.stat(resolvedPath);
// Check if it's a binary/media file
// Classify by extension. Known media types render with a dedicated player;
// other known-binary types are flagged so the client offers a download
// affordance instead of trying to decode the bytes as text. Matches the
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
// so the file viewer can open the same files.
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const binaryExts = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'ico',
'svg',
'bmp',
'mp4',
'webm',
'mov',
'avi',
'mp3',
'wav',
'ogg',
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']);
const otherBinaryExts = new Set([
'pdf',
'zip',
'tar',
'gz',
'bz2',
'xz',
'7z',
'rar',
'exe',
'dll',
'so',
'dylib',
'bin',
'wasm',
'class',
'o',
'a',
'woff',
'woff2',
'ttf',
'eot',
'otf',
'xlsx',
'xls',
'doc',
'docx',
'ppt',
'pptx',
'odt',
'ods',
'odp',
'avi',
'mkv',
'wmv',
'flv',
]);
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
if (raw === 'true' || binaryExts.has(ext)) {
// Return metadata for binary files
const mediaType = imageExts.has(ext)
? 'image'
: videoExts.has(ext)
? 'video'
: audioExts.has(ext)
? 'audio'
: null;
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body)
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
type: mediaType ?? 'binary',
extension: ext,
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
url: fileRawUrl,
},
};
}
@@ -213,10 +591,39 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
);
}
// Read as raw bytes so we can sniff for binary content before decoding. An
// unrecognized extension (none at all, or a format not listed above) that
// is actually binary would otherwise be dumped to the viewer as UTF-8
// mojibake; a NUL byte in the first 8KB is a reliable binary signal that
// (unlike a static extension list) catches arbitrary binary formats.
const fileBuffer = await fs.readFile(resolvedPath);
const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
const sniffLength = Math.min(buf.length, 8192);
let looksBinary = false;
for (let i = 0; i < sniffLength; i++) {
if (buf[i] === 0) {
looksBinary = true;
break;
}
}
if (looksBinary) {
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: 'binary',
extension: ext,
url: fileRawUrl,
},
};
}
// Read text file with line limit (bounded to prevent DoS)
const MAX_LINES_LIMIT = 10000;
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
const content = await fs.readFile(resolvedPath, 'utf-8');
const content = buf.toString('utf-8');
const allLines = content.split('\n');
const truncatedContent = allLines.length > maxLines;
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
@@ -284,9 +691,16 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
m4v: 'video/mp4',
ogv: 'video/ogg',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
oga: 'audio/ogg',
opus: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
pdf: 'application/pdf',
json: 'application/json',
};
@@ -315,6 +729,214 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}
});
// ===== Live external attachments =====
// Register an explicit, live external file (absolute host path) as an
// attachment with a stable id so browser requests never carry arbitrary
// paths. Registration enforces the COD-53 attachment-guard policy. Serving is
// by id via the /raw route below; document previews/thumbnails and the
// attachment-history list are layered on separately.
app.post('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const body = (req.body || {}) as { path?: string };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return;
}
try {
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
ctx.broadcast(SseEvent.AttachmentDetected, event);
return { success: true, data: event };
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
return;
}
return reply
.code(500)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`)
);
}
});
// List a session's attachment history (live session or persisted), resolving
// each entry to current metadata + routes. External entries are re-registered.
app.get('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const sessionHistory = getSessionAttachmentHistory(ctx, id);
if (!sessionHistory) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
return;
}
const items = await Promise.all(
sessionHistory.history.map((item) =>
(item.source === 'external'
? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
: buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
)
);
return {
success: true,
data: {
items,
count: items.length,
},
};
});
// Metadata poll for a single registered attachment (re-stats for live
// size/mtime as the underlying file is rewritten).
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return;
const event = attachmentRecordToEvent(record);
let size = record.size;
let mtimeMs = record.mtimeMs;
try {
const stat = await fs.stat(record.filePath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
// File temporarily unavailable mid-write — keep cached values.
}
return {
success: true,
data: {
path: record.fileName,
size,
mtimeMs,
type: record.attachmentType,
extension: record.extension,
url: event.rawUrl,
previewUrl: event.previewUrl,
thumbnailUrl: event.thumbnailUrl,
attachmentId: record.attachmentId,
fileName: record.fileName,
},
};
});
// Serve the raw bytes of a registered attachment by id. Re-checks the
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const session = findSessionOrFail(ctx, id);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
if (!servePath) return;
try {
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
// Serve a converted PDF preview of a registered attachment by id. Office docs
// convert server-side; PDF/PNG/text redirect to the raw route.
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
if (!servePath) return;
// Only Office formats need server-side conversion; PDF/PNG and text formats
// (md/txt) preview directly from their raw bytes.
if (record.extension !== 'docx' && record.extension !== 'pptx') {
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
return;
}
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
});
// Serve a first-page thumbnail of a registered attachment by id.
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
if (!servePath) return;
await serveThumbnail(reply, servePath, record.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
if (!filePath) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
const validated = validateSessionFilePath(workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const { resolvedPath } = validated;
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (ext !== 'docx' && ext !== 'pptx') {
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
return;
}
await serveConvertedPreview(reply, resolvedPath, filePath, ext);
});
// Serve a first-page thumbnail for a workspace-relative path.
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
if (!filePath) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
const validated = validateSessionFilePath(workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (!isSupportedAttachmentExtension(ext)) {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
return;
}
await serveThumbnail(reply, validated.resolvedPath, ext);
});
// Stream file content via tail -f (SSE endpoint)
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
const { id } = req.params as { id: string };
@@ -375,33 +997,20 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
});
});
// Close a file stream
// Close a file stream. Returns { closed } rather than { success: closed } —
// a top-level `success` key would collide with the envelope discriminator
// (the preSerialization hook would pass `{success:false}` through as a
// malformed error envelope instead of wrapping it).
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
const { id, streamId } = req.params as { id: string; streamId: string };
findSessionOrFail(ctx, id); // Validates session exists
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
return { closed };
});
// Session-scoped file download.
// Uses the same realpath-based workspace boundary as file preview/raw routes;
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
const SENSITIVE_PATTERNS: RegExp[] = [
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
// the shared sensitive-path blocklist (../sensitive-path.js, also used by the
// attachment guard) remains defense-in-depth, not the primary boundary.
app.get('/api/download', async (req, reply) => {
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
+1 -1
View File
@@ -66,6 +66,6 @@ export function registerHookEventRoutes(
summaryTracker.recordHookEvent(event, safeData);
}
return { success: true };
return {};
});
}
+1
View File
@@ -9,6 +9,7 @@ export { registerFileRoutes } from './file-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
+3 -3
View File
@@ -19,7 +19,7 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
app.delete('/api/mux-sessions/:sessionId', async (req) => {
const { sessionId } = req.params as { sessionId: string };
const success = await ctx.mux.killSession(sessionId);
return { success };
return { killed: success };
});
app.post('/api/mux-sessions/reconcile', async () => {
@@ -29,11 +29,11 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
app.post('/api/mux-sessions/stats/start', async () => {
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
return { success: true };
return {};
});
app.post('/api/mux-sessions/stats/stop', async () => {
ctx.mux.stopStatsCollection();
return { success: true };
return {};
});
}
+1 -1
View File
@@ -408,7 +408,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
}
return { success: true, data: tracker.getPlanHistory() };
return { success: true, data: { history: tracker.getPlanHistory(), currentVersion: tracker.planVersion } };
});
// ========== Rollback to Version ==========
+2 -2
View File
@@ -35,7 +35,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
if (!updated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
}
return { success: true };
return {};
});
app.delete('/api/push/subscribe/:id', async (req) => {
@@ -44,6 +44,6 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
if (!removed) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
}
return { success: true };
return {};
});
}
+16 -37
View File
@@ -9,13 +9,14 @@ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { Session } from '../../session.js';
import { Session, isExternalCliMode } from '../../session.js';
import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { buildRalphLoopPrompt } from '../../prompts/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
@@ -44,9 +45,12 @@ export function registerRalphRoutes(
};
const session = findSessionOrFail(ctx, id);
// Ralph tracker is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Ralph tracker is not supported for ${session.mode} sessions`
);
}
// Handle reset first (before other config)
@@ -101,7 +105,7 @@ export function registerRalphRoutes(
state: session.ralphLoopState,
});
return { success: true };
return {};
});
// Reset circuit breaker for Ralph tracker
@@ -110,7 +114,7 @@ export function registerRalphRoutes(
const session = findSessionOrFail(ctx, id);
session.ralphTracker.resetCircuitBreaker();
return { success: true };
return {};
});
// Get Ralph status block and circuit breaker state
@@ -379,37 +383,12 @@ export function registerRalphRoutes(
writeFileSync(fixPlanPath, planContent, 'utf-8');
}
// Build full prompt
const hasPlan = enabledItems.length > 0;
let fullPrompt = taskDescription + '\n\n---\n\n';
if (hasPlan) {
fullPrompt += '## Task Plan\n\n';
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
fullPrompt += '- Reference the plan at the start of each iteration\n';
fullPrompt += '- Update task checkboxes as you complete items\n';
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
}
fullPrompt += '## Iteration Protocol\n\n';
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
fullPrompt += '1. Check what work has already been done\n';
fullPrompt += '2. Make incremental progress toward completion\n';
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
fullPrompt += '## Verification\n\n';
fullPrompt += 'After each significant change:\n';
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
fullPrompt += '- Check for type/lint errors if applicable\n';
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
fullPrompt += '## Completion Criteria\n\n';
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
fullPrompt += '- All requirements from the task description are implemented\n';
fullPrompt += '- All tests pass\n';
fullPrompt += '- Changes are committed\n\n';
fullPrompt += '## If Stuck\n\n';
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
fullPrompt += "1. Document what you've tried\n";
fullPrompt += '2. Identify the specific blocker\n';
fullPrompt += '3. Try an alternative approach\n';
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
// Build full prompt (includes the RALPH_STATUS contract)
const fullPrompt = buildRalphLoopPrompt({
taskDescription,
completionPhrase,
hasPlan: enabledItems.length > 0,
});
// Write prompt to file
const promptPath = join(casePath, '@ralph_prompt.md');
+17 -17
View File
@@ -11,6 +11,7 @@ import { SseEvent } from '../sse-events.js';
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { isExternalCliMode } from '../../session.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
@@ -62,16 +63,16 @@ export function registerRespawnRoutes(
const controller = ctx.respawnControllers.get(id);
if (controller) {
return { success: true, config: controller.getConfig(), active: true };
return { config: controller.getConfig(), active: true };
}
// Return pre-saved config from mux-sessions.json
const preConfig = ctx.mux.getSession(id)?.respawnConfig;
if (preConfig) {
return { success: true, config: preConfig, active: false };
return { config: preConfig, active: false };
}
return { success: true, config: null, active: false };
return { config: null, active: false };
});
// ═══════════════════════════════════════════════════════════════
@@ -88,9 +89,9 @@ export function registerRespawnRoutes(
}
const session = findSessionOrFail(ctx, id);
// Respawn is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
// Create or get existing controller
@@ -114,7 +115,7 @@ export function registerRespawnRoutes(
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
return { success: true, status: controller.getStatus() };
return { status: controller.getStatus() };
});
// ========== Stop Respawn ==========
@@ -150,7 +151,7 @@ export function registerRespawnRoutes(
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
return { success: true };
return {};
});
// ========== Update Respawn Config ==========
@@ -169,7 +170,7 @@ export function registerRespawnRoutes(
ctx.saveRespawnConfig(id, controller.getConfig());
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() });
return { success: true, config: controller.getConfig() };
return { config: controller.getConfig() };
}
// No controller running - save as pre-config for when respawn starts
@@ -206,7 +207,7 @@ export function registerRespawnRoutes(
ctx.mux.updateRespawnConfig(id, merged);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged });
return { success: true, config: merged };
return { config: merged };
});
// ═══════════════════════════════════════════════════════════════
@@ -231,9 +232,9 @@ export function registerRespawnRoutes(
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
// Respawn is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
try {
@@ -296,9 +297,9 @@ export function registerRespawnRoutes(
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
const session = findSessionOrFail(ctx, id);
// Respawn is not supported for opencode sessions
if (session.mode === 'opencode') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
// Check if session is running (has a PID)
@@ -332,7 +333,6 @@ export function registerRespawnRoutes(
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
return {
success: true,
message: 'Respawn enabled on existing session',
respawnStatus: controller.getStatus(),
};

Some files were not shown because too many files have changed in this diff Show More