Compare commits

..
Author SHA1 Message Date
Codeman maintainer ecf28fa3be chore: version packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:37:13 +01:00
Codeman maintainer 8a6e34bece docs: list both unauthenticated-exposure acknowledgements as stable
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK and the new
CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL (#718), with their CLI flags, join
the documented environment variables the versioning policy covers, from
1.46.0, the release that gave each its own meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:36:15 +01:00
Codeman maintainer c04fa97f78 chore: replace the em-dashes this release added with plain punctuation
The merged PRs (mostly #714's git fetch) added about 120 em-dashes in
comments, docs, test names and three settings strings. Each one is now a
comma, colon, semicolon or parentheses. The English settings strings in
index.html and their i18n.js keys change together, and the zh-CN value
for the fetch interval uses full-width parentheses instead of a double
dash. The one left is the regex in constants.js that matches a dash
typed as a word, which is behaviour, not punctuation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:11:19 +01:00
Codeman maintainer de1aab9b86 chore: one changeset for 1.46.0
Replaces the six contributor changesets (#694, #707, #708, #709, #718,
#713) with one consolidated minor changeset covering all 14 PRs, with the
Thanks section first and the release-review fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:11:52 +01:00
Codeman maintainer cc08016a76 fix: keep links in the columns right of a wrapped URL's first row (release review)
terminalLinkLine() cut the URL's first row at its cell to stitch the URL, so a
path in a later column of that row was never scanned. The cut-off columns now
follow the stitched rows as a segment of their own, mapped back to their cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:08:33 +01:00
Codeman maintainer 93691e6096 fix: bring the skill, plugin, README and API docs in line with the merged agent behaviour (release review)
The self-gate now says the skill reads saved Codex conversations offline outside a session; the staging
reference says only tagged input gets 409 during a guarded submit (the rest is held), and the API
reference notes a managed write past a full hold queue falls back to a direct write. Also: missing
reference rows, agent --help naming inspect/stage/submit, the tunnel opt-in in Remote Access, MCP wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:04:14 +01:00
Codeman maintainer 3fdba31e36 fix: keep a shell prompt whose spaced working directory ends in $ out of a spaced path link (release review)
spacedFilePathOnLine() refused only a FIRST word ending in `$`, so `/mnt/c/My Docs$ ls notes.md`
and `/mnt/c/Users/me/OneDrive - Contoso/proj$ code notes.md` linked the whole line. Any earlier
word ending in `$` now refuses the candidate, and both lines link what master links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:00:44 +01:00
Codeman maintainer 1752d7ee2c fix: refuse to git fetch while the Docker case list cannot be read (release review)
readDockerCases() reads an unreadable or half-written docker-cases.json as [], so a fetch (which runs
repository hooks, and runs unattended on the interval) could run inside a Docker workspace. The fetch
paths now read it through a strict reader: the Fetch button answers a failed run (state 'error') and the
scheduler skips the tick, undoes its picks and logs once. The status and diff routes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:58:37 +01:00
Codeman maintainer b89f15c12f fix: make the tunnel-guard test fail on a settings.json write that skips the temp file and rename (release review)
settingsWrites() now requires every writeFile to be a settings.json temp file that a rename put in
place, with one rename per write, and the concurrent-stop harness stores a direct write to
settings.json at once. A stray fs.writeFile(SETTINGS_PATH) in the PUT handler passed all 34 tests
before and now fails 20 of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:54:19 +01:00
Codeman maintainer da740d4b00 fix: stop logging settings.json contents from the git-fetch tick, and log an unreadable file once (release review)
The settings readers for git fetch and keep-warm no longer repeat V8's JSON.parse message, which
quotes the text around the fault and could print voiceSettings.apiKey. The 15 s scheduler tick now
logs an unreadable settings.json once, and again only after a successful read in between. Stale
comments that said settings.json is rewritten in place now say writes are atomic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:53:08 +01:00
Codeman maintainer 0f1fca6ea0 Merge pull request #712: feat(tabs): start a session beside any tab from an opt-in + in its actions 2026-10-27 02:24:17 +01:00
Codeman maintainer 98526527c8 Merge pull request #711: fix(mobile): hold the PTY rows while the on-screen keyboard is up 2026-10-27 02:24:17 +01:00
Codeman maintainer b21b8485c3 Merge pull request #710: test(deepseek): stop pinning Node's one-shot zstd decoder to the first frame 2026-10-27 02:24:17 +01:00
Codeman maintainer 5908cc51fd Merge pull request #694: feat(agent): COD-529 add bounded inspection and guarded submission
Conflicts resolved by keeping both sides:
- CLAUDE.md (Agent CLI): #694's opening (an HTTP client, the experimental
  agent-session-routes behind agentCoordinationEnabled) with #708's clause
  on the directory spawn and its two refusals.
- docs/api-reference.md (codeman agent): #708's spawn paragraph and --json
  shape, with #694's inspect/stage/submit in the verb list and the guarded
  endpoints named before "adds no route for the other verbs".
- src/cli-agent.ts (@fileoverview): #708's quick-start / directory spawn
  routes and #694's experimental routes in one endpoint list; the two
  node:fs/promises imports (#708's realpath+stat, #694's open+stat) merged
  into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:14:44 +01:00
Codeman maintainer 01cd509c2c Merge pull request #708: feat(agent): COD-549 launch workers in existing project directories 2026-10-27 02:12:50 +01:00
Codeman maintainer 6612a89ebc Merge pull request #714: feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case
Conflict in CLAUDE.md (Adding Features, App setting) resolved by keeping
both sides: #714's sentence that a setting the server acts on must be
synced, after the `.strict()` clause, and #719's mutateSettingsFile() rule
at the end of the bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:49 +01:00
Codeman maintainer 6d07a1059f Merge pull request #717: fix: cycle Command-K CLI picker both ways at query boundaries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:29 +01:00
Codeman maintainer bd3320ff90 Merge pull request #709: fix(agent): COD-549 preserve worker IDs after readiness failures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:29 +01:00
Codeman maintainer cad8621324 Merge pull request #707: feat(history): COD-549 read saved Codex conversations offline
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:29 +01:00
Codeman maintainer 97d2998c0c Merge pull request #713: perf(zerolag): rebuild only the changed overlay line, one paint layer per line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:29 +01:00
Codeman maintainer 9c27342c7f Merge pull request #687: feat(terminal): link file paths that contain spaces as one path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:29 +01:00
Codeman maintainer 3d0abbf21c Merge pull request #686: feat(terminal): link URLs and paths that wrap across rows or sit in table cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:11:29 +01:00
Codeman maintainer 82ef8b1edd Merge pull request #719: fix(settings): serialize settings.json writes so a quick-start cannot tear the file
Conflict in src/web/routes/system-routes.ts (PUT /api/settings) resolved by
keeping both sides: #718's line that never stores an unauthorized
tunnelEnabled:true runs first, then #719's serialized mutateSettingsFile()
write replaces the inline read and plain writeFile.

test/routes/system-routes-tunnel-guard.test.ts: #718's new tests asserted
plain writeFile calls on settings.json. With #719 every write goes to a temp
file renamed over it, so the assertions read writes through a settingsWrites()
helper (writeFile contents keyed by their rename), and the concurrent-stop
harness applies a write and the stop at the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:10:35 +01:00
Codeman maintainer 97c91d4fbd Merge pull request #718: fix(tunnel): give the unauthenticated tunnel its own acknowledgement 2026-10-27 02:08:27 +01:00
Codeman maintainer 6368128f1e fix: apply the review items for #694 at merge
- src/web/routes/session-routes.ts:2102: the guarded-submit 409 now applies to tagged input (clientId + seq) only; untagged input is held like a WebSocket frame. The raw write (:2228) goes through holdForGuardedLease and answers 200 once held (a send-and-wait reads `delivered` from the held write; a full queue releases the waiter and answers 409); a mux write takes its own turn in writeViaMux.
- test/guarded-lease-held-input.test.ts: real-Session route tests for an untagged raw POST during the lease (written after the Enter) and a held send-and-wait (delivered: true); both fail without the fix.
- test/routes/session-routes.test.ts + test/mocks/mock-session.ts: the 409 test now covers tagged input only; new tests for untagged raw (held), untagged mux (own turn) and a full hold queue (409); MockSession gains holdForGuardedLease/releaseGuardedLease and a lease-aware writeViaMux.
- src/session.ts:4787 (guardedInputHeld JSDoc) + write() + docs/architecture-invariants.md: write() refuses during the lease only, so cron's paste mode can land ahead of input still draining.
- docs/api-reference.md: the input route's 409 is for tagged input; untagged input is held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:07:39 +01:00
Codeman maintainer 6539bc6c82 fix: apply the review items for #714 at merge
- Session update no longer resets "How often" while typing: updateGitFetchStatus
  (session-ui.js) leaves a control alone while it is document.activeElement, and
  autoSaveGitFetch writes the clamped interval back itself (Enter saves with the
  field still focused).
- Partial override shows the app values: enabled = session ?? app switch,
  interval = session ?? app interval ?? 300 (session-ui.js updateGitFetchStatus).
- runFetch (git-fetch.ts) arms the spawn with what the core.sshCommand read left
  of timeoutMs, and answers ETIMEDOUT without spawning when nothing is left, so a
  git that never answers costs one timeout, not two.
- fetchEnvFor keeps a GIT_SSH from the server's environment (repoSshCommand ||
  base.GIT_SSH), fileoverview and CLAUDE.md line updated to say so.
- i18n.js: the zh-CN "Git fetch on an interval" description scopes the only
  writing git command to the Git window.
- styles.css: the disabled/busy rules are scoped to .git-status-actions
  .btn-icon-sm (moved beside .git-status-actions), and the busy pulse stops
  under prefers-reduced-motion.
- Comments that called the override per case now say per session: session.ts
  (field, restore and getter docs, setGitFetchSettings), git-fetch-scheduler.ts:2,
  server-timing.ts:78, server.ts (cleanup and restore), app.js _onSessionUpdated,
  session-ui.js (openSessionOptions and updateGitFetchStatus).
- Tests: test/git-fetch-session-options-ui.test.ts (new, JSDOM: typing survives
  an SSE update, the Enter write-back, both partial cases); test/git-fetch.test.ts
  (GIT_SSH kept; one bound for the read and the fetch, stub git on PATH).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:58:14 +01:00
Codeman maintainer 4b3eebd0ed fix: apply the review items for #708 at merge
- test/cli-agent-project-spawn.test.ts:345: the doc-sync test no longer reads .changeset/project-worker-directories.md (changeset version deletes it at release); README, API reference and wiki stay.
- CLAUDE.md:132 + src/cli-agent.ts @fileoverview (invariant 5): the directory spawn (POST /api/v1/sessions plus /interactive or /shell, no Agent-Origin header, so no marker) and its refusals (empty --working-dir, bare spawn from a HOME cwd); the invariants now name test/cli-agent-project-spawn.test.ts too.
- test/cli-agent-project-spawn.test.ts:322: "allows a cwd below HOME" mocks the cwd to <temp HOME>/src/app, so it covers the case the guard must leave alone.
- docs/wiki/Driving-Codeman-From-An-Agent.md:59: split the sentence so exit 4 covers only the empty --working-dir; spawn "" reaches quick-start and exits 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:48:21 +01:00
Codeman maintainer e52cc60c7c fix: apply the review items for #687 at merge
- constants.js:2296,2301 spacedFilePathOnLine: refuse a first word ending in `$`
  and an earlier word with no letter or digit (other than a bare `-`), so a
  shell prompt showing an absolute working directory (`/mnt/c/proj ❯ code
  notes.md`, `/home/me/proj$ cat notes.md`) keeps master's links; the six rows
  from the review are pinned, plus OneDrive, `Program Files (x86)` and
  `Docs - Copy` still linking whole.
- constants.js:2312: the leading strip is now `/^(?:~?\/|(?:\.\.?\/)+)/`, so
  `Saved to: ../My Docs/plan.md` links whole; an interior `..` still links
  nothing. Tests for the detector and the terminal provider.
- app.js:3032 _linkifyFilePaths: carry any leading bold label and let
  spacedFilePathOnLine() judge it, and give a text node that follows another
  inline element a placeholder word, so `**Note:** /home/me/proj/out
  report.pdf` and ``Use `--config` /home/me/proj/cfg data.json`` split as in
  the terminal; a spaced path inside inline code still links whole. The
  now-unused label-pattern guard is gone.
- JSDoc (findFilePathLinks, SPACED_FILE_PATH_LABEL_PATTERN,
  spacedFilePathOnLine) and the spaced-path paragraph in
  docs/architecture-invariants.md updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:34:15 +01:00
Codeman maintainer 04aa44b69c fix: apply the review items for #712 at merge
- session-ui.js _reloadCasesAfterRun: reload onto the toolbar's own case after every launch, not only one that names its case; comment updated
- test/tab-new-session.test.ts: a worktree tab's "+" Claude launch, the toolbar Run and a group's "New session" each keep the toolbar's case after a "+" moved lastUsedCase (fail without the fix); the reload setup is shared with the existing lastUsedCase test
- docs/architecture-invariants.md:349: the _reloadCasesAfterRun sentence covers every launch
- app.js _openTabLayoutMenu focusout: focus landing on the trigger is not a departure, so pressing the "+" again closes its picker in a real browser
- tab-rail-resize.js openTabRailActionMenu: close the group menu too, so the rail's ... pressed again does not leave the picker beside the row menu
- tab-rail-resize.js:329-331: the "New session here…" comment no longer says Enter on the "+" does nothing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:30:07 +01:00
Codeman maintainer ffa7f57c8a fix: apply the review items for #717 at merge
- test/command-palette-agent-arrows.browser.test.ts: drop the page.screenshot()
  that wrote /tmp/codeman-21-palette.png on every browser-suite run (nothing read it)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:26:33 +01:00
Codeman maintainer 8574e3c880 fix: apply the review items for #719 at merge
- src/web/settings-writer.ts:110: export assertSettingsWritable(), the writer's own read-for-write rule run on its own
- src/web/routes/case-routes.ts:1860: an admin's rename checks settings.json before it ends any session or moves the folder, and answers 409 CONFLICT with the writer's message (placed before the session-starting check, so nothing is awaited between it and the lock)
- test/routes/case-rename-routes.test.ts:314: the torn-file pin now expects 409 CONFLICT (no stoppedSessions, like the other pre-checks)
- test/routes/case-rename-routes.test.ts:331: new test, a live session with stopSessions and a torn file: 409 CONFLICT, cleanupSession not called
- docs/api-reference.md: the Settings paragraph says an admin's rename answers this refusal as 409 CONFLICT before ending sessions
- src/hooks-config.ts:1161, src/prompt-cache-keepwarm.ts:341: comments describe the atomic writer, not an in-place rewrite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:24:15 +01:00
Codeman maintainer e93382f2f1 fix: apply the review items for #686 at merge
- constants.js:2618 terminalUrlContinuationExtent: trailingColumns now needs a
  one-row first line (first.startRow === first.endRow), so a URL row that
  soft-wraps with a column on its wrapped part no longer glues the next row
  onto that column (`/tmp/report.txtccc/x.html` at 41 columns); master's links
  come back. Negative tests for the extent and the link provider.
- constants.js:2847 terminalCellWrappedPaths: comment reworded to match the
  JSDoc (the exception is a left-column PATH), plus the wrapped-prose case.
- docs/architecture-invariants.md, CLAUDE.md: one clause that a left column of
  wrapped prose (a Description | Path table) also fills the continuation row,
  so the path beside it keeps its per-row fragment links, the safe direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:23:56 +01:00
Codeman maintainer e9f3aa20a5 fix: apply the review items for #710 at merge
- docs/architecture-invariants.md DeepSeek mechanism (1): the decoder stops at the first frame only before Node 24.22 and 26.10 (nodejs/node#65865)
- src/deepseek-transcript.ts:33: reflow the over-long comment line to the width of the lines around it
- docs/deepseek-integration.md:241: same reflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:21:56 +01:00
Codeman maintainer e8f66c6446 fix: apply the review items for #711 at merge
- terminal-ui.js _applyKeyboardClip(): read container.clientHeight only while rows are held, so the release path and the setting-off path skip the forced layout on every render and cursor move
- constants.js keyboardClipPx JSDoc: drop the stray "ponytail:" marker, the note reads as plain text
- src/web/CLAUDE.md "Per-device vs synced settings": list keyboardHoldsRows among the per-device keys SettingsUpdateSchema does not declare

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:21:51 +01:00
Codeman maintainer cd1b231876 fix: apply the review items for #718 at merge
- docs/api-reference.md:1222: the tunnel status shape is { running, url, loginConfigured } (getStatus() returns only running and url)
- src/web/routes/system-routes.ts:1233: the unauthenticated-tunnel warning keys on !isLoginConfigured(), so it no longer fires in multi-user mode
- test/install-sh-invariants.test.ts:537: install.sh must not contain --allow-unauthenticated-tunnel either (it writes service argv as well as env)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:21:22 +01:00
Aamer Akhter 2aad93914e fix(agent): hold input during a guarded submit instead of refusing it
The input lease is held across the awaited tmux Enter, so WebSocket frames
keep arriving while it is held. A tagged frame (seq N) was refused and its
seq rolled back, but the browser's WebSocket path does not wait for ACKs:
seq N+1 landed after the lease and moved the watermark past N, and the
redelivered N was then dropped as a duplicate. The keystroke was lost.

Input that arrives during the lease is now held and written in arrival order
when the lease is released, whatever the submit's outcome. A WebSocket frame
is applied and ACKed only once written; managed writes (writeViaMux) and
named keys (send-key) wait for their turn the same way, and a held write that
is still being sent holds everything behind it. A keystroke held before the
guard runs still moves the input revision, so it still invalidates the Enter.
The hold is capped per lease (256 inputs, 1 MiB): past it the WebSocket is
closed with 1013 and nothing later from it is applied, so the browser resends
every unACKed frame in order, and send-key answers 409 CONFLICT instead of
logging a tmux failure as INTERNAL_ERROR. POST .../input keeps its 409 for the
lease plus the held input's drain; its browser drainer is serialized.

Docs: correct the api-reference paragraph that said these keystrokes are
redelivered, and describe the capture fallback and the per-scope cursor cap
from the previous two commits in api-reference, architecture-invariants and
the changeset.
2026-10-26 09:19:05 -04:00
Aamer Akhter ecaddb40ae fix(agent): cap inspection cursors per owner and session
Cursors shared one global FIFO of 1,024 and were never retired on use, so one
agent polling every second evicted every other caller's cursors after about
17 minutes. Each owner and session now keeps its newest 8 cursors, older ones
retired first, under the same global backstop; the cursor just used stays
among the newest, so a retry of the last call still works.
2026-10-26 09:04:53 -04:00
Aamer Akhter a29192a248 fix(agent): do not rerun a pane capture whose combined call failed
The combined capture-pane and cursor query swallowed a thrown error and the
caller then ran the same capture again, so a hung tmux blocked the event loop
for two 5 s timeouts on every visible-frame capture, which serves every
GET .../terminal load. A thrown error now reaches the capture's own catch;
only output that splitFrameAndCursor cannot split falls back to the two
separate calls. The capture runs through a captureRunner() seam, like
inputRunner(), so the tests count the tmux calls each path makes.
2026-10-26 09:04:52 -04:00
Aamer Akhter 003398e209 fix(agent): refuse an implicit HOME cwd for a directory spawn
`codeman agent spawn` always sends its directory as an explicit
workingDir, so the server cannot apply its no-workingDir rule and
installs workspace hooks (and an injected skill) into ~/.claude when the
caller's cwd is HOME. A bare spawn whose cwd resolves to the home
directory now refuses with exit 4 before any request and names
`--working-dir ~`; an explicit `--working-dir ~` is still sent. Paths
are compared by realpath, so a symlinked HOME matches, and an
unresolvable HOME or cwd falls back to the resolved path without
throwing.

The directory-spawn --json shape now reads the same in the README, the
API reference, the wiki page and the changeset: `ready` when a
readiness wait ran, `readinessError` when that call failed, and
`startupError` when the start failed. A test pins all four.
2026-10-26 08:51:15 -04:00
JD fc0ecc6051 fix(tunnel): give the unauthenticated tunnel its own acknowledgement
The network acknowledgement that install.sh writes for every passwordless LAN install also satisfied the tunnel guard, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. The tunnel now needs CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL or --allow-unauthenticated-tunnel (carried by web -d and service install), and with no login the browser asks before every enable, reading a new loginConfigured field from GET /api/tunnel/status.

The guard is now decided once per request and required again where the tunnel starts, so a save that found the tunnel running can no longer restart it unguarded after a concurrent stop.

Closes #706
2026-10-26 03:13:25 -04:00
JD 8fa5b340c1 fix(settings): serialize settings.json writes so a quick-start cannot tear the file
Quick-start saved lastUsedCase with an un-awaited, non-atomic read-modify-write, and the settings PUT, model config, case order, case rename, Ralph start and the boot tunnel reset each ran their own. Two overlapping truncate-then-write calls left one write's bytes over the tail of the other (the CI flake in test/quick-start.test.ts), and most writers then read the torn file as {} and wrote back only its own keys, erasing the rest of the user's settings.

All of them now go through one writer that runs one change at a time, replaces the file atomically, and refuses (409 CONFLICT) to overwrite a file that does not parse.
2026-10-26 03:08:09 -04:00
JD 4f6481bf7c fix(tabs): open the tab + from the keyboard and probe a Docker tab's container
The + announces a menu but Enter and Space did nothing on it, and the header strip has no row menu to fall back on. A + on a Docker tab the toolbar has not selected read only the link-time mode list, since nothing probed its container.

Opening the picker from the keyboard focuses an entry, so a held Enter or Space repeated onto it and launched that CLI; the shared tab-layout menu now ignores a repeated Enter or Space.
2026-10-26 03:00:23 -04:00
Saqeb Akhter c0d27079d7 test: register palette keyboard browser regression (johoja12/Codeman#21) 2026-10-26 01:16:02 +04:00
Saqeb Akhter 0cc9332f3d fix: cycle Command-K CLI picker at query edges (johoja12/Codeman#21) 2026-10-26 01:14:48 +04:00
Devvyn 3411d7f35a fix(git): the fetch timeout reaches the whole tree, a tick covers every session on its folder, and the rest of the review (review)
`execFile` signals the `git` parent only, so a fetch that times out can leave its http helper
still running, and on the interval that repeats every run with nobody watching. `runFetch` now
spawns `detached`, so the fetch and the helper it spawns share one process group, and the kill
is SIGTERM to the negative pid then SIGKILL `FETCH_KILL_ESCALATION_MS` later; the escalation is
not cancelled when the promise settles, because the caller is answered at the timeout while the
group is still dying. The output cap is kept, and the clone's slot queue is not needed here.

The tick is two passes: every enabled local session is collected by folder, a folder is due when
ANY session open on it is due, the folder is probed once for the group, and the run is published
to every session on it. A single pass decided by the order the sessions were read, so [long,
short] published only the short one and the 3600 s session's row never updated again while its
folder was fetched every 30 s.

`gitNonInteractiveEnv` writes BatchMode ssh as a default, not an override: `core.sshCommand` is
read inside the same slot and the same bound as the fetch it serves, and `fetchEnvFor` keeps the
repository's answer when it has one, because that is usually the setup that works on a machine
with several ssh setups. The `accept-new` host key it can write is stated next to the BatchMode
note: a prompt is stopped, a write is not.

The write claim is scoped to the Git panel and to the timer, with cloning and worktree management
named as the writes that happen when the user asked for them, and the zh keys sit with the group
of the UI surface their English string belongs to.

Tests: the process-group test proves the tree by group membership, because the helper is a
grandchild (`git fetch` runs `git remote-http`, which spawns `git-remote-http`) and because the
group is what the negative pid reaches, and it remembers each process once found instead of
re-scanning a group it had already emptied. The non-zero-exit test changed premises: `git fetch
--quiet` in a repository with no remote exits 0 on git 2.47.3, so resolving empty stdout there was
correct, and the case that has to reject is a remote pointing at a path that is not a repository.
The join test asserts after the run and does not await the request before the held run is
released, which would wait on the run the test is holding.
2026-10-25 22:31:25 +08:00
Aamer Akhter 2832a037d6 fix(agent): verify staged text on a live codex pane and refuse popups
Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and
issues a receipt only for a frame with the same generation, the input
revision its own write moved, and a composer hash equal to the
message's. Codex repaints 30 to 60 ms after the keys, so the first
capture was often stale and stage refused with the text already typed.
Every refusal after typing now says the message may be in the composer
and points at the Ctrl+U recovery. A visible-frame capture reads the
screen and the caret in one tmux invocation, with the old two calls as
the fallback.

Codex draws its suggestion popups above the composer and keeps the
footer, so a staged /stat verified and submit ran /status. Stage now
refuses a leading / or ! and a last word starting with @ or $ before
typing, and stage and submit refuse a frame with popup rows just above
the composer. The pattern and characters are registry data
(composerPopupLine, composerCommandPrefixes, composerCompletionSigils),
read from the codex 0.162.0 bottom_pane sources.

Submit waits until 250 ms after the staged keys: codex turns an Enter
within 120 ms of a typed burst into a newline. POST .../input answers
409 with its seq returned while a guarded submit holds the input lease,
instead of 200 for bytes it never wrote.

Also align the attribution memo comment with the code (a verified file
whose incremental scan fails is not re-walked, since the full walk
reads a superset), and list stage as a fromUser source in CLAUDE.md.
2026-10-25 09:22:35 -04:00
Aamer Akhter ae3d31407b fix(terminal): keep a labelled table record out of a cell-wrapped path
A wrapper wraps every column of one table row together, so the row that
carries a cell's continuation is blank to the left of that cell unless a
path in a left column continued on it too. A label there (`Entry`,
`│ Helper │`) opens the next record, so a directory ending in `/` or `-`
(`Workspace  /home/u/proj/app/` over `Entry  main.py`) no longer takes
the next record's file name.

What still joins is a trailing-slash directory over a relative path in the
same column with nothing to its left, which reads exactly like a wrap at
`/`; that trade-off stays pinned.
2026-10-25 09:12:04 -04:00
Aamer Akhter 17b2edce22 fix(history): count compressed rollouts, read each file once, degrade an unreadable parent
Codex's local_thread_store_compression rewrites cold rollouts to
*.jsonl.zst, which the store scan skipped without a trace. Each one is now
counted in compressedFiles (complete: false), skipped only when its plain
.jsonl sibling exists, as Codex's own discovery does, and a lookup that
misses names the compressed file whose name carries the wanted id. A
Zstandard file given to --file, or sitting in the store under a .jsonl
name, is recognised by its magic number.

Each selected rollout is parsed once into every view later needed of it,
so a legacy copy's boundary proof reuses the parent's own read instead of
charging it to --scan-bytes a second time (and counting its malformed or
inherited records twice).

A parent read that fails for any reason other than a budget now leaves
the boundary unproven (cut at the last candidate, unverifiedPrefixes,
complete: false) instead of failing a child whose own file is intact. A
budget error still fails the read.

--max-bytes is validated before the first file is read, next to the
other budgets, and every budget flag accepts plain decimal integers only.
2026-10-25 09:08:54 -04:00
Aamer Akhter 8ad60facf3 fix(terminal): keep a directory cell from taking the next row's file name
A table cell holding a whole directory (`/home/u/proj` over `config.yaml`)
was joined onto the file name in the same column on the next row, which is
the next record, and the joined link then replaced the correct links on both
cells. A wrapper breaks a path after `/` or `-` (codex wraps cells on
Unicode line-break opportunities plus a hyphen splitter, which never break at
`_`), so a cell fragment must end on one of those before the next row can
continue it.

A directory written with its trailing slash over a relative path in the same
column still joins, since it reads exactly like a wrap at `/`; pinned as a
known trade-off.
2026-10-25 09:03:29 -04:00
Aamer Akhter b26234d6c6 fix(agent): refuse an empty --working-dir instead of spawning in cwd
`codeman agent spawn --working-dir "$WT"` with WT empty (a failed
`git worktree add`, say) resolved '' to the caller's cwd and started an
editing worker in the caller's own checkout. An empty or
whitespace-only --working-dir now refuses with exit 4 before any
request.

The emitJson comment names the directory-spawn exception to "the
envelope's data": `{ sessionId, workingDir, created: true, ready }`.

Tests: unit (`''`, spaces, tab: exit 4, no calls) and through the real
argument parser and HTTP transport (nothing sent, exit 4).

Docs: README, api-reference, wiki, SKILL.md (plugin mirror synced) and
the changeset note the refusal.
2026-10-25 09:03:15 -04:00
Devvyn ea4318947b fix(git): the interval fetches one run per folder, and the rest of the review (review)
The scheduler is the part that runs with nobody watching, so it is decided per folder
rather than per session: sessions sharing a folder are the same clone and share one run
that both see, `inFlight`/`lastRunAt` are keyed by the resolved folder, the schedule is
stamped when a folder is picked instead of when its run starts, and a tick is skipped
while the previous one is still going. The click route joins a run already going for the
same folder rather than starting a second fetch in one clone.

The tick keeps ONE bound for the git processes of the whole run and hands it to every
folder, and bounds its folders with a bound of its own: the same semaphore cannot serve
both levels, because a folder holding a slot would then wait for a slot its own fetch
needs. Both bounds are `FETCH_CONCURRENCY`, so the ceiling is unchanged. The bound claim
in CLAUDE.md, the `Limiter` jsdoc and docs/api-reference.md now describes it as it is.

Before anything is fetched the folder is probed with the bounded path probe, so a case on
a mount that stopped answering is skipped and never touched.

The rest of the review: the hooks are named as they really are (`reference-transaction`,
`pre-auto-gc` through the maintenance a fetch triggers, config commands such as
`credential.helper`) and `FETCH_CONFIG_ARGS` turns maintenance and gc off for an
unattended run; the claim that an agent in the same repository is not disturbed is
replaced by the ref-lock collision it actually is; the click route publishes its own run
as `trigger: 'click'`; `autoSaveGitFetch` uses `_apiJson` so a 400 shows the error toast
and applies the returned settings; and the two wording fixes.

Tests: the scheduler pins one run per folder, the two bounds, the probe before anything
runs, and that closing the session holding the override keeps the folder while another
session is still open on it; the route test pins the click publish and the join, asserting
the join before the held run is released — releasing first lets the run finish and clears
the lane, so the joiner would be entitled to its own fetch and the test would pass for the
wrong reason.
2026-10-25 20:04:01 +08:00
Devvyn 9432d9f9e2 feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case
Fetch is the only git command Codeman runs that writes, and the only one that runs a
repository's own fetch-post hook, so it stays behind a click and off by default. The Git
status window's header row gains Fetch; App Settings -> Bottom bar gains the switch and
the interval, synced because the scheduler acts on them, and Session Options -> Git
overrides both per case. The scheduler reads settings fresh each tick, times the interval
from the start of a run, never overlaps runs, and re-checks the Docker and remote rules
every tick before any git runs. Nothing but remote-tracking refs moves: HEAD, the index
and the working tree are proven unchanged against real git while origin/main advances.

Tests: src/git-fetch.ts and the scheduler, the per-case settings route, the synced/display
setting split, the two new i18n rows, and the Fetch block of the Git status browser suite.
2026-10-25 17:23:55 +08:00
Codeman maintainer 1082724db9 perf(zerolag): rebuild only the changed overlay line, one paint layer per line
The local echo overlay rebuilt every character of the prompt on each
keystroke, one absolutely positioned span per character, so a keystroke
cost O(prompt length) in DOM work, style, layout and paint. Every
positioned span was also its own paint layer, so the browser re-layerized
and repainted thousands of them per frame. In headless Chromium with a 6x
CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame,
and ~300 characters already missed 60 fps (54 ms).

renderOverlay() now keeps a per-container record of the line elements it
drew and rebuilds only lines whose text, position or composition range
changed. It starts over when the font, colour, cell size or Unicode
version changes, or when something else emptied or edited the container.

makeLine() lays the spans out in a flex row instead of positioning each
one, and sets the shared font styles once on the line. Cell edges are
snapped to a quarter pixel, which Blink, WebKit and Gecko all store
exactly, so the widths add up to the grid without drift and every edge
stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 06:07:34 +01:00
Aamer Akhter 44fa2d4757 docs(history): document the offline reader next to codeman agent
Replace docs/offline-history.md, which repeated the skill reference and was
linked from nowhere, with a README section and a wiki section beside the
codeman agent commands. Bring the skill reference, its plugin mirror, the
Codeman Skill wiki page and the changeset in line with the reader: forks,
the separate budgets and their flags, UTC dates, phases and complete:false.
2026-10-24 23:00:50 -04:00
Aamer Akhter 002d876556 fix(history): read forked threads, large stores and live rollouts
A forked or spawned thread's rollout carries its own session_meta followed
by its parent's and a copy of the parent's history. A later session_meta
for a declared ancestor now opens an inherited prefix that is dropped:
by ordinal below subagent_history_start_ordinal in paginated history, and
up to the thread_settings_applied event Codex appends after the copy in
legacy history, checked against the parent's own messages when the copy
holds more than one candidate. An unrelated id still refuses the file.

Store attribution now has its own budget (--metadata-bytes) separate from
the transcript budget (--scan-bytes), reads each head in 32 KiB steps, and
--max-entries is a flag, so id lookups work on stores of thousands of
rollouts. An unattributable store file or unreadable directory is counted
instead of aborting the lookup, and a rollout that a running Codex appends
to is read as a snapshot up to its last complete line.

User records carry no phase, date bounds are UTC calendar dates, the
injected-context rule is the shared one, a symlinked sessions root is
followed as the Past Sessions scanner follows it, and --json with
--format text is refused.
2026-10-24 23:00:49 -04:00
Aamer Akhter 803e7c7f85 refactor(codex): share the rollout format between the two Codex history readers
The Past Sessions scanner and the offline history reader each parsed Codex
rollouts with their own shape helpers, their own CODEX_HOME resolution and
different injected-context rules. Move the store location, the three message
shapes and the injected-context rule into codex-rollout-format.ts. The
scanner's behaviour is unchanged; the offline reader adopts its rule in the
next commit.
2026-10-24 23:00:49 -04:00
Aamer Akhter c78635a3d3 feat(agent): make coordination routes opt-in and address review
Gate inspect, stage and submit behind the synced agentCoordinationEnabled
setting (default OFF, App Settings > Cross-session features, zh-CN
strings). While off, all three routes answer 403 FORBIDDEN naming the
setting, checked before session lookup; the CLI verbs print it verbatim.

Remember the attributed Codex rollout per session and re-verify only its
head on later calls, rescanning only date directories not older than it
for a /new conflict. The first walk reads 1 KiB prefixes first, so a
store of 2,100+ unrelated rollouts still attributes.

Answer unexpected inspection errors with 422 OPERATION_FAILED and log
the session id and error code only.

Read the guarded composer glyph and footer from the CLI registry: codex
workDetect.promptGlyph and a new workDetect.composerFooterLine
capability, compiled through compileVersionRegex. Codex footer effort
words are one shared list with modelDetect.screenLine.

Fix the wiki API reference link, frame the versioning policy entry as a
case of the experimental rule, join the CLAUDE.md Agent CLI paragraph,
and update the changeset, skill reference and architecture notes.
2026-10-24 22:54:17 -04:00
Aamer Akhter 1bdc341784 fix(terminal): keep code, fields and measurements out of wrapped URLs
A URL ending in `/` still took the next indented row when that row was
code, a label, a log or config field, a measurement or the end of a
sentence. A side-by-side sweep against master turned up shapes such as
`ready.` under a codex `Listening on` line, `expect(x).toBe(y)` under a
jest title, `Failure/Error:` under an rspec title, `API_KEY=...` under
`BASE_URL=...`, `level=info` log fields, `45%|####` tqdm bars,
`12.3MB/45.0MB`, `12.3ms` and `10:00:00.123`.

A continuation is now refused when it ends with a colon, when it is a
measurement or clock time, or when it is a `name=value` token under a
URL ending in `/` (a query needs `?` first). A token with only `-`, `_`
or `.` is also refused when it carries a call paren or ends a sentence.
Wiki titles such as `Mercury_(planet)`, a file name before its period,
query parameters after `?`, `&` or `_`, and percent-encoding that starts
with digits still join.

`self-hosted.` moves from the pinned trade-offs to the negatives; the
trade-off is now a lone word without sentence punctuation.
2026-10-24 22:50:23 -04:00
Aamer Akhter df9f93d3ee fix(terminal): keep sentences and script commands out of spaced paths
A side-by-side sweep of master and this branch over about 590 realistic lines
found more shapes where a spaced candidate swallowed prose or a command:

- An earlier word that is a lowercase connective or verb (and, or, to, in,
  the, is, has, contains, then, as and a few more) now makes the candidate a
  sentence, for own-line, list-marker and labelled forms alike:
  `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`,
  `Path: /home/me/proj/out contains a.md`, `Files: src/components and
  utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. `at` and
  `of` stay out so screenshot names and `Copy of report.docx` still link
  whole; a name such as `Pros and Cons.md` splits as on master.
- A bare `=` word is refused like the arrows.
- An executable under `scripts/` or `tools/` is refused like one under
  `bin/` (`/home/me/proj/scripts/deploy prod.yml`).
- An earlier word holding an extension anywhere (`a.ts#L12`) ends the path.

The two residuals pinned earlier (`/home/me/proj contains a.md` and
`Files: src/components and utils/x.ts`) now return master's links.
2026-10-24 22:47:44 -04:00
Aamer Akhter 77ce4816a8 fix(agent): keep the error code when a request fails without a message
Node 22 rejects a refused connection to a host that resolves to both ::1
and 127.0.0.1 with an AggregateError whose message is empty and whose code
is ECONNREFUSED. The readiness fallback dropped that code, and the agent
CLI's top-level error line and the MCP server's "Cannot reach Codeman"
error printed nothing at all for it.

Add describeRequestError() (message, else code, else a generic line) and
use it at all three sites.
2026-10-24 22:39:58 -04:00
Aamer Akhter c5ffea0ed2 fix(agent): treat only an omitted case as a directory spawn, and document the directory launch
`codeman agent spawn ""` (for example `spawn "$CASE"` with CASE unset)
used to launch a worker in the caller's cwd. Both case checks now test
`caseName !== undefined`, so an empty name reaches quick-start and is
refused by its schema as before.

Drop the `{ id }` fallback on the create answer: `POST /api/sessions`
answers `{ session: { id } }`, and the test fakes now use that shape.

Tests: an empty case name (unit and through the real argument parser
and HTTP transport), the claude `--ready` order create, /interactive,
wait-output on the created id, and a non-JSON failed start (id alone on
stdout, reason on stderr, exit 1).

Docs: the spawn mapping and directory-launch JSON shape in
docs/api-reference.md, the README and wiki `codeman agent` sections, the
cli-agent @fileoverview and the skill reference tables; reflow the new
SKILL.md paragraph (plugin mirror synced); the changeset notes the
local exit-4 refusal of an unknown mode for named cases.
2026-10-24 22:39:16 -04:00
Aamer Akhter 1a2c3be0ea docs(agent): mark coordination API experimental 2026-10-24 22:37:18 -04:00
Aamer Akhter 4c2f050a96 fix(agent): address inspection and staging review feedback 2026-10-24 22:37:18 -04:00
Aamer Akhter a24b0ec7bb feat(agent): add bounded inspection and guarded submission 2026-10-24 22:37:06 -04:00
Aamer Akhter 5e1daaf7c7 fix(terminal): keep label prose out of labelled spaced paths
A path label says a path follows, not that the words before it belong
to the path. Restore the guard that defers a relative candidate whose
only slash word is its last, so `Wrote: 120 lines to src/foo.ts` links
src/foo.ts as on master. `Saved to: My Docs/plan.md` and
`Saved to: 03 Attachments/plan.pptx` split again as a documented
trade-off; `Files: src/components and utils/x.ts` is pinned as the
residual the guard does not catch.

Refuse command lines whose executable sits under a bin/ or .bin/
directory, or that carry a flag word, so they keep master's links.

Update the JSDoc example, the CLAUDE.md pointer and the
architecture-invariants paragraph.
2026-10-24 22:28:57 -04:00
Aamer Akhter 2a90fb8118 fix(terminal): keep the next table record out of a last-column URL
A URL in the last column of a space-aligned table ended on a slash, so
the first cell of the next record (billing-api, CHANGELOG.md, ...)
passed every continuation test and was joined onto it. The alignment
check that refused this only ran when other columns followed the URL.

A continuation row must now start inside the URL's own cell: a gap of
two or more spaces between its indent and where the URL starts on the
first row means it opens an earlier column. An indented token that is
a whole email address is also refused.

Adds the six table shapes and the email case as negative tests at
detector and provider level, and documents the rule.
2026-10-24 22:28:29 -04:00
Aamer Akhter d25d71b595 fix(agent): COD-549 report readiness failures with empty errors 2026-10-24 20:45:00 -04:00
Aamer Akhter b2e96ddfaf feat(agent): COD-549 start workers in existing project directories 2026-10-24 20:44:15 -04:00
Aamer Akhter e8ea8b0715 feat(history): COD-549 read saved Codex conversations offline 2026-10-24 20:43:17 -04:00
Aamer Akhter af4b65697a fix(agent): COD-549 preserve spawned ID on readiness transport errors 2026-10-24 20:41:14 -04:00
JD c7835a159c feat(tabs): start a session beside any tab from an opt-in + in its actions
Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case.

The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons.

The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
2026-10-24 20:29:00 -04:00
JD 3e334357e8 fix(mobile): hold the PTY rows while the on-screen keyboard is up
Every keyboard open and close resized the PTY, and Claude Code's inline renderer answers each height change by clearing the screen and reprinting only the transcript tail that fits, so rows above that tail were lost from tmux for good. Measured on Claude Code 2.1.306 with tmux 3.2a: five keyboard cycles (62x38 to 62x15 and back) left 75 of 100 numbered lines in the pane's history; with the rows held the PTY stays 62x38, no SIGWINCH reaches the pane and all 100 remain (#672).

While the keyboard is up the rows captured at the show transition are held through getTerminalDimensions, columns still follow the box, and the screen, now taller than its box, is translated up by --kb-clip so the last content row sits at the bottom of the visible area. This is the one documented exception to the columns-only geometry rule: there the extra rows came from another device and nothing kept the composer in view, here they are this device's own rows held on purpose and the clip keeps the composer visible. The local-echo overlay's setRowLimit narrows the renderer's existing totalRows bound, so one place decides where a long draft stops growing downward.

Only inline renderers hold, through a new registry capability keyboardHold: 'inline' (Claude alone) published to the page as window.__codemanKeyboardHoldModes. Full-height layouts such as codex, Claude's fullscreen mode (mouse tracking on, unmeasured), and remote or docker panes resize as before. The hold is released on keyboard close, a shape change, a tab switch, a session update, a reconnect, or the setting turned off. Per-device setting Keep Size Under Keyboard, default on.
2026-10-24 20:17:45 -04:00
Aamer Akhter 405c9928d9 fix(terminal): end spaced paths at line suffixes and generic labels
A spaced path is no longer taken across a `path:line` suffix (grep -n, rg -n
and compiler output) and an absolute path after a generic label such as
`Error:` or `Note:` goes back to the ordinary detectors: any label must now
be a path label. `Saved the report to: /mnt/c/My Docs/a.docx` splits as on
master, pinned as a documented trade-off.

Also:
- a rooted candidate needs a directory before its first space, and an
  unlinkable one reserves its span only while its last word holds a `/`, so
  `/usr/bin/env node build.js` and `/review the changes in src/foo.ts` keep
  their links while `/etc/secret files/passwords.txt` still links nothing
- a run of spaces is a column gap, not part of a name
- a leading `./` is not read as a segment
- the response viewer carries a bold path label that starts a later line of
  a paragraph (after a `<br>`), and degrades if the label pattern is missing
- response viewer tests render with the options chat uses
2026-10-24 19:55:11 -04:00
Aamer Akhter 3a4e2c996e fix(terminal): keep shell prompts and status lines out of wrapped URLs
A URL ending a row on `/` took the next row's first word whenever it
carried URL punctuation, so a shell prompt, a home path or an HTTP
status line could be appended to it. Refuse those tokens, and refuse a
column-0 row as a hard continuation (real continuations are indented),
which also keeps `status=200`, `24/7` and `really?` out.

A divider row no longer continues the row above it, so a URL reaching
the right edge does not absorb a full-width rule below it.

Pin the new negatives at detector and provider level, re-indent the
positive continuation fixtures, and pin the `self-hosted.` and `and/or`
trade-offs.
2026-10-24 19:54:40 -04:00
JD 8cdd18fa64 test(deepseek): stop pinning Node's one-shot zstd decoder to the first frame
The frame-walking test asserted that zlib.zstdDecompressSync returns only the first of 40 concatenated frames. That was true when the walker was written, but nodejs/node#65865 (shipped in 26.10.0 and in 24.22.0 LTS) makes Node decode every frame, so the assertion fails on current Node 24 LTS and 26 while the walker itself is still correct. The assertion tested Node, not Codeman: the walker's own 40-frame checks stay, and the comments now say which Node versions stop at the first frame, since the supported range (>=22) still includes them.
2026-10-24 18:56:37 -04:00
Codeman maintainer 3e06f3c141 chore: version packages
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 17:19:51 +02:00
Codeman maintainer 48ad9fc7ca fix: document the case-rename route as open to non-admins, with only its settings.json part admin-only (release review)
docs/api-reference.md listed POST /api/cases/:name/rename in the Settings
table, whose introduction says every route in it answers 403 FORBIDDEN to a
non-admin in multi-user mode, and its paragraph said caseOrder and
lastUsedCase are always updated. Neither is what the route does (and what
CLAUDE.md, the wiki and test/routes/case-rename-routes.test.ts say): a
non-admin renames their own case (200), and only the server-wide
settings.json write is skipped (`if (isAdmin(req))`).

The row leaves the admin-only table (its body moves into the paragraph), the
paragraph says the route is not admin-only and that caseOrder/lastUsedCase
follow an admin's rename only, and a linked case's 404 for a non-admin is
spelled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 16:57:42 +02:00
Codeman maintainer f8029c2a9e fix: refuse a case folder move while a session is still being created in it (release review)
The rename lock is checked once, in the Session constructor, but a session
reaches the server's map (where the rename route builds its list of open
sessions) only later, after the create awaits the owner's layout lock and a
fresh settings read (and, for POST /api/sessions, inferLocalCaseName). A
rename in that gap neither listed nor stopped the session, moved the folder,
and the create then registered a session whose pane starts with
`cd "<old path>"`, skipping the 409 requiresStop / stopSessions guarantee.

- case-rename.ts keeps a record of sessions constructed but not yet
  registered (`noteCaseSessionStarting` / `caseSessionSettled` /
  `isCaseSessionStarting`), with a 30 s TTL for one that never registers
  (a failed create, a one-shot prompt), swept on every note and check.
- The Session constructor records itself (local sessions only, as the
  route ignores remote ones); registerSessionWithLayout()'s register
  callback, the boot-restore registration and stop() settle it.
- The rename route answers 409 CONFLICT with renameCode BUSY ("A session is
  starting in this case") for a folder move while one is recorded at or
  under the folder. The check runs in the same tick that takes the lock, so
  every interleaving is either refused here or by the constructor. An
  alias-only rename is not refused.

A create whose path was validated before the move but whose Session is
constructed after it is still the ordinary check-then-use race of any
create path; CLAUDE.md now says so instead of "nothing starts mid-move".

Tests: unit (record, settle, stop, TTL), route (BUSY, then requiresStop once
registered; alias-only unaffected) and an end-to-end WebServer test that
holds the create between constructor and registration (it fails without
the route check: the folder moves under the session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 16:57:20 +02:00
Codeman maintainer 40158ce1ef fix: give the host-sensor readers a hard deadline so a hung nvidia-smi cannot hold the stats route (release review)
`run()` relied on execFile's `timeout`, which only sends the child a
signal: execFile's callback, and so the promise, settles when the child
exits. An nvidia-smi stuck on a hung driver (uninterruptible sleep) never
does, so `readNativeGpu()` stayed pending, every later `?gpu=1` request
joined that promise through `inFlight`, and since the route awaits the
sensors before `getSystemStats()`, CPU and MEM froze too for every device
with GPU Stats on. The backoff (`nvidiaSkipUntil`) only armed once the
child finally exited.

`run()` now races its own unref'd timer and answers undefined at the
deadline, clearing the timer when execFile settles first. The existing
failure path then arms the 60 s backoff as for any failing reader. The
PowerShell RAM probe shares `run()` and gets the same bound.

Test: an nvidia-smi that never calls back answers within the deadline, the
next poll does not run it again, and its late exit changes nothing (the
test fails on the previous `run()`). Checked against a real TERM-ignoring
child on PATH: the read settles at 1.5 s instead of when the child exits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 16:46:31 +02:00
Codeman maintainer fb83a5c8df fix: keep the host readings' labels under header density, hide ringless ones at Compact tiny (release review)
#689's density steps hide every `.header-system-stats .stat-label` once the
toolbar is narrowed, and #692's readings (GPU, VRAM, GPU MHz, CPU MHz) have
nothing else naming them: at tight, dense and tiny the header showed "40%"
beside CPU's "12%", "3.2G" beside MEM's "7.9G", and two bare "1800 MHz" /
"4200 MHz" with no way to tell the GPU clock from the CPU one. At Compact +
tiny, where values are hidden too, the two clocks (no ring) were left as
0-px items that still took an 8 px flex gap each. Neither PR shows it alone.

- The four host items carry `stat-item--host`, excluded from the density
  label hide, so they keep their labels at every step.
- `stat-item--ringless` (static on the two clocks, toggled by
  `_updateHostSensors` on VRAM while its total, and so its ring, is unknown)
  is hidden at Compact + tiny instead of staying an empty item.

Tests: the browser test drives every density in all three styles against
the real markup and styles.css (it fails on the previous files), and the
unit test pins the VRAM ringless toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 16:45:05 +02:00
Codeman maintainer e739b2e9d4 Merge pull request #690: feat(agent): per-session agent inbox (discussion #558)
Conflict in src/web/routes/session-routes.ts (the import block) resolved
by keeping both sides: #680's claudeProjectDirName import stays, and the
fastify import takes #690's form without FastifyReply, since #690 moved
abortOnClientHangUp (its only user here) into route-helpers.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:29:15 +02:00
Codeman maintainer 2fdbe786b3 Merge pull request #680: feat(cases): rename a case and its folder, with a lock and live-session handling 2026-10-24 15:27:46 +02:00
Codeman maintainer c7b6226cb0 Merge pull request #692: feat(header): opt-in GPU and CPU clock readings after CPU and MEM
Conflict in config/test-suites.ts resolved by keeping both sides: #689's
header-divider and #692's header-host-sensors browser tests are both
listed in BROWSER_TEST_GLOBS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:25:02 +02:00
Codeman maintainer aad67f2056 Merge pull request #689: feat(header): resizable toolbar divider that collapses the usage readings as it narrows 2026-10-24 15:23:39 +02:00
Codeman maintainer 395d8293a7 Merge pull request #685: feat(palette): choose the agent when starting a session from Cmd+K 2026-10-24 15:23:11 +02:00
Codeman maintainer edf3cb19e4 Merge pull request #699: fix(terminal): a mouse wheel notch pages the CLI's transcript at once
Conflict in docs/architecture-invariants.md resolved by keeping both
sides: the hollow-pane paragraph carries #681's unloaded-scrollback
sentence, #698's Codex-menu rewording and #699's notch rewording (a
word-level three-way merge; the three edits touch disjoint sentences).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:22:43 +02:00
Codeman maintainer 0160db9927 Merge pull request #698: fix(terminal): the wheel no longer moves a Codex selection menu's selection
Conflicts resolved by keeping both sides:
- src/web/public/terminal-ui.js (_maybePageCliTranscript): #698's
  Codex-menu consume check sits right after the open-dialog consume
  check (its sibling, as the docblock lists them), and #681's
  unloaded-scrollback fall-through (paneHistoryLines > 0) follows it.
- docs/architecture-invariants.md: the hollow-pane paragraph carries
  #681's unloaded-scrollback sentence and #698's menu rewording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:21:58 +02:00
Codeman maintainer d38044e2ac Merge pull request #681: fix(terminal): load tmux history instead of paging Claude after a hollow tab switch 2026-10-24 15:19:16 +02:00
Codeman maintainer 4dfe978d3f Merge pull request #682: fix(mobile): swipe through sessions in the order the strip draws them 2026-10-24 15:16:49 +02:00
Codeman maintainer 053615f9ae Merge pull request #684: fix(auth): widen the per-IP login throttle to 50, keep the per-account lockout at 10 2026-10-24 15:16:07 +02:00
Codeman maintainer ef9fc8244d Merge pull request #678: fix(sse): retry an EventSource connect attempt that never opens 2026-10-24 15:15:42 +02:00
Codeman maintainer 23725b039b Merge pull request #688: fix(tmux): stop nested prefix keys and TUI Alt+digits from moving other clients 2026-10-24 15:14:58 +02:00
Codeman maintainer 2b551541d9 Merge pull request #691: fix(remote): use a shared quoted PATH for all remote tmux operations 2026-10-24 15:14:25 +02:00
Codeman maintainer 98b0d10e34 Merge pull request #677: fix(tmux): send prompts that start with a hyphen literally 2026-10-24 15:14:02 +02:00
Codeman maintainer 1b7cfe1f2c Merge pull request #683: fix(auth): require the request on findSessionOrFail 2026-10-24 15:13:29 +02:00
Codeman maintainer f6e21a7c50 fix(palette): offer a Docker case's container agents in Cmd+K, as the Run menu does
The new-session row's agents now go through _runModeAvailableForCase (session-ui.js), the rule extracted from _refreshRunModeAvailability, for the case the row launches in: a Docker case offers what its container has (live probe, else availableModes, else ungated), so a container-only Run mode is offered and is the row's default again.
The default is resolved per render (pick, else the Run button's mode, else the first offered), so a query that moves the row to another case starts on the Run mode when that case offers it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:10:16 +02:00
Codeman maintainer 5a8fc853c3 fix(auth): share one verify between identical in-flight logins
Reserving a slot per request refused a correct password with the slots it had
filled itself: after a restart the browser replays its cached Basic header on many
parallel requests. Identical credentials from one IP now join the verify already in
flight for them (keyed on a SHA-256 digest), skipping the gates and its scrypt cost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:54:44 +02:00
Codeman maintainer 1444c41250 fix: apply the review items for #691 at merge
test: pin the double-quoted PATH literal of withRemoteTmuxPath() (dash 0.5.11+, bash and busybox pass the shell cases unquoted)
docs: note in architecture-invariants that a new remote pane inherits the widened PATH from the creating tmux client

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:52:26 +02:00
Saqeb Akhter 5b9445de9e fix(remote): bootstrap PATH consistently for remote tmux (johoja12/Codeman#15) 2026-10-24 14:52:26 +02:00
Codeman maintainer db0742177b fix: address the release-prep review of #690
cleanup-state keeps the agent inbox of a removed record whose tmux session the multiplexer still lists (a detached pane the next boot re-adopts under the same id); test and architecture-invariants note added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:48:05 +02:00
Codeman maintainer 46774b71f9 fix: address the release-prep review of #688
Give a client that fails the M-1..M-9 guard its key back (send-keys <key> as the if-shell else argument), so a browser tab's Esc then digit reaches its pane instead of being eaten, also after a TUI killed before restore(); only M-1..M-9 are accepted since the key is now interpolated into that command
Test it: the unit test pins the else argument and the key check, and the real-tmux test runs cat -v in alpha and waits for the other client's Esc 2 to echo there, both before and after the TUI dies
Say so in docs/architecture-invariants.md and docs/tui.md, and reword the killed-TUI and failed-arm notes that said the keys do nothing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:45:34 +02:00
Codeman maintainer f1010da8a3 fix(auth): reserve the throttle slots before the scrypt verify
In multi-user mode the per-IP and (username, IP) gates read their counts before
the verify and wrote them after it, so a concurrent burst from one IP passed both
on one stale read (29 wrong guesses plus the right password logged in). A request
that passes both gates now takes its slot before the await; a success clears it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:44:02 +02:00
Codeman maintainer d1e0e0aea6 fix: address the release-prep review of #680
Claude history merges, never overwriting, into a project directory a deleted case of the new name left behind, instead of staying orphaned under the old key.
The free-target pre-check also refuses a dangling symlink (lstat), so stopSessions no longer ends sessions before a 409 EXISTS.
The rename dialog says stopped sessions are closed (their tabs go away) and, after a failed rename, how many were already closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:43:13 +02:00
Codeman maintainer fac47f067d fix: address the release-prep review of #685
Keep the Run button's mode when fewer than two agents are offered (_commandPaletteAgent), so a Docker case whose host offers only Shell still starts the container agent the Run button shows; pinned by a new palette test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:41:50 +02:00
Codeman maintainer af682d77ae fix: address the release-prep review of #699
terminal-ui.js: the _maybePageCliTranscript JSDoc names PAGE_KEY_NOTCH_DEBT_MS as the stretched gap after a notch-sized opener
terminal-scroll-routing.test.ts: the notch-opener test comment uses the real numbers (36 rows, 14 rows owed)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:41:32 +02:00
Codeman maintainer d920375e5e fix: address the release-prep review of #677
test: record sendInput('-x hello') and sendInput('-\r') and check the shell argv ends -l -- <text> on both send-keys -l branches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:37:05 +02:00
Codeman maintainer 5222239966 fix: address the release-prep review of #678
test/tile-grid-sse-filter.test.ts: search for the renamed 'const eventSource = new EventSource(' end marker and require both ends, so the connect-URL check covers that block again instead of the rest of app.js
test/sse-connect-timeout.test.ts: cover the replaced-EventSource guard in _handleSseConnectionFailure() (a late error from a replaced attempt leaves the new one alone)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:36:42 +02:00
Codeman maintainer 9dec12b6e7 fix: address the release-prep review of #684
Re-read the per-IP failure count after the scrypt await, so concurrent failures from one IP are all counted (regression test added).
Reword the (username, IP) bucket comment: it grows with failed verifies per pair, not no faster than userFailures.
Reword the verify-first test comment: only the per-username bucket never denies a correct password.
Correct docs/multi-user-plan.md: the per-username bucket does not stop cross-IP brute force; the per-(username, IP) bucket caps verified guesses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:32:36 +02:00
Codeman maintainer 9ba24a02df fix: apply the review items for #690 at merge
Boot prune keeps the inboxes of sessions on the reboot-restore offer (their records are already pruned and a rebuild reuses the id), with a case in test/agent-inbox-server-wiring.test.ts.
docs/wiki: post, inbox and ack in the codeman agent command block of Driving-Codeman-From-An-Agent, and "Keep agent inbox across restarts" in Settings-Reference.
codeman agent inbox prints message text through terminalSafeText (ANSI stripped, other C0, DEL and C1 controls shown as \xHH); --json stays as stored.
Shutdown flushes the inbox snapshot again after app.close(), for a post that lands after the first flush.
POST /api/cleanup-state drops the inboxes of the records it removes.
InboxPostSchema derives its fence from MAX_MESSAGE_BYTES instead of the literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:15:49 +02:00
Codeman maintainer 7783fcd0ce fix: apply the review items for #692 at merge
host-sensors: never await the PowerShell RAM probe on a read; keep the resolved total and show the memory in use alone until it answers (test with a held PowerShell)
test: rename the Windows sampler test that said the GPU counters are added "for good"
test: make host-sensors.test.ts's @fileoverview describe what that file covers
host-sensors: make the nvidiaSkipUntil JSDoc describe the back-off deadline
docs: Settings Reference says the host readings live inside System Stats and nothing is fetched while it is off

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:15:22 +02:00
Codeman maintainer b7897b6d0e fix: apply the review items for #684 at merge
Multi-user: add a (username, IP) failure bucket checked before verifyPassword, capped at AUTH_FAILURE_MAX, with the same decay as the other buckets; the per-IP pre-gate stays at AUTH_IP_FAILURE_MAX and the per-username bucket stays after the verify
A correct password now also clears that account's (username, IP) bucket, beside the IP and per-username buckets
The new bucket is disposed on server stop like the others; the comments above the gate and the constants' JSDoc describe the three buckets
Tests: 10 wrong then the correct password from one IP is a 429, the same account logs in from another IP, another account behind that IP is unaffected, and a correct password clears the pair bucket
test/multiuser-auth.test.ts: the spray test loops to AUTH_IP_FAILURE_MAX instead of a literal 50
Reworded the rate-limit lines in auth-config.ts, security-architecture.md, README.md, README.zh-CN.md, wiki/Security.md, endpoints.md (plus the plugin mirror), CLAUDE.md and architecture-invariants.md
docs/wiki/Remote-Access.md: add the multi-user note to the tunnel page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:15:21 +02:00
Codeman maintainer 2936063ea3 fix: apply the review items for #699 at merge
pageKeysForGesture comment (terminal-ui.js:218): the gap stretches to PAGE_KEY_NOTCH_DEBT_MS after a notch-sized opener, not only PAGE_KEY_DEBT_MS
pageKeysForGesture comment (terminal-ui.js:286): a repaid debt ends either lifetime, not only the one-second one

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:14:56 +02:00
Codeman maintainer 80e7bb1031 fix: apply the review items for #698 at merge
changeset: say Codex does not track the mouse "measured on 0.162.1 and 0.164.0", since 0.163.0 fullscreen was measured tracking it
docs/architecture-invariants.md: list page-keys-dialog-open and page-keys-menu-open among the [scroll] decisions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:13:15 +02:00
Codeman maintainer 311d01c52b fix: apply the review items for #688 at merge
disarmTuiSwitchGuard matches the closed `#{client_tty},<tty>}` form, so a TUI on /dev/pts/4 no longer removes the hook armed for a second TUI on /dev/pts/41 (unit test plus a real-tmux case)
beginAttachHandoff arms the one-shot attach hook as its last tmux call before spawnSync, after the switch keys and the per-session dressing loop, with an order assertion in tui-app.test.ts and a sentence in the invariants paragraph
docs/remote-sessions.md and the buildRemoteLaunchCommand JSDoc show `remain-on-exit failed` at the end of the remote create chain

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:10:07 +02:00
Codeman maintainer 34a2966c19 fix: apply the review items for #689 at merge
Clamp the stored toolbar width to MIN_WIDTH in apply() and step the arrow keys from the width on screen, so a drag to the window edge or a held ArrowRight no longer stores a negative width or leaves an ArrowLeft dead zone (browser test added)
Drop Escape as a reset key on the handle: the global capture-phase Escape handler already closes every panel on it (static pin added)
Say in the changeset, the wiki and the densityFor comment that the percentages go at the narrowest step in the Compact style only, and drop Escape from the changeset

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:08:37 +02:00
Codeman maintainer d16172c4b8 fix: apply the review items for #685 at merge
palette agent switch: aria-hidden on .command-palette-agent, arrows lose role="button" and aria-label (no nested buttons in the row)
i18n: zh-CN entry for the "Agent for the new session (Left/Right to change)" tooltip
docs: Keyboard-Shortcuts Ctrl+K row says Left/Right change the agent on the New session row
test: pin the aria-hidden switch and the zh-CN tooltip entry in command-palette-ui.test.ts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:05:12 +02:00
Codeman maintainer 4da6aee660 fix: apply the review items for #682 at merge
docs: architecture-invariants (arrow-key walk section) names the phone swipe beside the arrow keys as following the drawn order
docs: root CLAUDE.md (Owner tab layouts) carves the phone swipe out of "every other order consumer reads sessionOrder"
test: pin the grouped-tree branch of _stripSessionOrder() (sorted rail, header and web tab skipped, groups not interleaved)
test: the step-in case uses a drawn order (homelab, w1, w2) that master's canonical walk and the old arithmetic cannot satisfy

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:05:05 +02:00
Codeman maintainer d41ad16818 fix: apply the review items for #681 at merge
_notePaneHistory JSDoc names the responses it skips (an error envelope on any path, a refresh that lost its owner, a pull whose select generation moved)
selectSession records the pane count only for a non-error envelope (capture.json?.success !== false), pinned in the static guard of test/pane-history-memory.test.ts; the two invariant paragraphs now name the tab switch beside the refresh and the pull

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:02:21 +02:00
Codeman maintainer ac6d4aa2db fix: apply the review items for #680 at merge
Restore settings.json (caseOrder, lastUsedCase) when linked-cases.json cannot be written, with the rollback test extended
List Claude Code's per-project ~/.claude.json entry as not migrated in Core-Concepts.md and CLAUDE.md
Say in the 500 that the folder is now at the new path when moving it back fails
Describe the rename dialog's folder checkbox in Core-Concepts.md, carry stoppedSessions on errors, count 5 case SSE events

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:01:33 +02:00
Codeman maintainer f4f9001a4b fix: apply the review items for #678 at merge
docs: document the SSE connect timeout next to the staleness watchdog in src/web/CLAUDE.md and docs/architecture-invariants.md (clear order, replaced-EventSource guards, offline handler leaves the timer)
test: rename the offline case to "connectSSE() while offline cancels the pending timer" so it names the path it drives

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:59:17 +02:00
Codeman maintainer 0eb9255465 fix: apply the review items for #677 at merge
src/tmux-manager.ts: escapeTmuxTrailingSemicolon() JSDoc says every send-keys -l passes -- before the text, and its example uses the -l -- form
test/tmux-submit-paste.test.ts: the trailing-semicolon wiring test matches -l (?:-- )? so it pins only the semicolon rule
test/tmux-submit-paste.test.ts: master's two recorded-command tests for typed text expect the -l -- form (merge interaction with the inputRunner() refactor)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:57:37 +02:00
Codeman maintainer 886bf9fa0b Merge remote-tracking branch 'origin/master' into relprep/pr-678 2026-10-24 13:55:07 +02:00
Codeman maintainer effb1ef7a5 Merge remote-tracking branch 'origin/master' into relprep/pr-677
# Conflicts:
#	src/tmux-manager.ts
2026-10-24 13:54:44 +02:00
Codeman maintainer 3d6b48a4cd fix: apply the review items for #683 at merge
findPersistedSessionOrFail now requires req (dropped the `?` and the dead `req &&` guard), like findSessionOrFail
The AST test in test/ownership-scoping.test.ts pins both declarations and asserts req's type is exactly FastifyRequest

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:53:13 +02:00
Devvyn 23a9d78cdd fix(host-sensors): never wait on a cold sampler, back off failing readers, one stats request at a time 2026-10-24 10:17:20 +08:00
Devvyn 77eefce4f6 fix(terminal): carry leftover travel into a notch's page, document the new rule 2026-10-24 10:12:58 +08:00
Devvyn 372df864b9 fix(terminal): review of the Codex menu wheel guard 2026-10-24 10:10:38 +08:00
Devvyn 81beec7b5b fix(terminal): a mouse wheel notch pages the CLI's transcript at once 2026-10-24 08:46:38 +08:00
Devvyn 9fa6d98c7c fix(terminal): the wheel no longer moves a Codex selection menu's selection 2026-10-24 08:28:52 +08:00
Devvyn f15afe1de3 test(settings): poll again when settings.json is read half-written 2026-10-24 00:32:20 +08:00
Devvyn ce7d198b91 fix(host-sensors): one shared Windows sampler, guard late rows, address review 2026-10-24 00:05:21 +08:00
Aamer Akhter 27c93b20fb fix(terminal): stop URL continuations from swallowing prose 2026-10-23 10:00:38 -04:00
Devvyn e1d6471808 feat(header): opt-in GPU and CPU clock readings after CPU and MEM 2026-10-23 21:36:10 +08:00
Aamer Akhter 74a107cf4f fix(terminal): require path labels and preserve markdown context 2026-10-23 09:01:44 -04:00
Devvyn f3c90abeb0 fix(header): settle the divider when readings change, keep density to the header layout, restore the chosen width 2026-10-23 19:58:40 +08:00
Aamer Akhter 40a9528be1 fix(terminal): keep unrelated rows out of wrapped links 2026-10-23 07:26:15 -04:00
Aamer Akhter efa647d257 fix(terminal): exclude relative prose and trim spaced paths linearly 2026-10-23 07:26:15 -04:00
Devvyn fa2d75c951 feat(header): resizable toolbar divider that collapses the usage readings as it narrows 2026-10-23 19:01:06 +08:00
RandalixandClaude Opus 5.5 a1a33cf050 fix(agent-inbox): review round 1
- codeman agent post: accept text that starts with "-" (markdown from a
  file), --json and -h stay options.
- DELETE /api/sessions/:id on a persisted-only row drops the inbox a detach
  kept.
- inbox --wait: wait.ended is exit 3 only when the session list no longer has
  the session; a restart or detach ending the wait is exit 2 (wait again).
- A read whose client hung up during ?wait= marks nothing seen.
- InboxPostSchema bound is 4x the byte cap, so every oversize message gets
  the store's "post a file path" answer.
- Store: restore sorts oldest first; the TTL sweep checks every message,
  not only the first.
- Docs: admins can act as any session of the receiver's owner (now tested
  for admin and non-admin); a peek with ?wait= does not block; exit codes.
- Tests: partial PUT /api/settings keeps persistence on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 12:45:48 +02:00
RandalixandClaude Opus 5.5 c6dfaa2cac feat(agent): per-session agent inbox (discussion #558)
A coordinator can leave text for a worker without typing into its pane.

- Store (src/web/agent-inbox.ts): read until ack by id, seen only for the
  receiver, 16 KiB/message, 512 KiB + 200 messages per inbox, full = 409
  CONFLICT (never drops the oldest), 12 h TTL from each message's createdAt.
- Routes (routes/inbox-routes.ts): POST/GET /api/sessions/:id/inbox
  (?wait= parks while nothing is unseen), POST .../inbox/ack (receiver
  only, 403 otherwise), GET /api/agent-inbox/summary (owner-scoped counts,
  not in toState()). Caller = resolveParentSessionId().
- Inbox waiters are the wait registry's third kind: same caps, ended by
  cancelAll/cancelEverything.
- Opt-in snapshot (agentInboxPersistEnabled, synced, default off):
  agent-inbox.json via dataPath(), 0600 tmp+rename, off deletes it, boot
  prune only after a successful restore. Dropped on delete, kept on detach.
- CLI: codeman agent post | inbox [--wait] | ack, INBOX column in ls.
- waitCapacityResponse/abortOnClientHangUp moved to route-helpers.ts
  unchanged.
- Docs: api-reference, README, CLAUDE.md, architecture-invariants, skill +
  plugin mirror.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 12:45:48 +02:00
DDTandDDT 9fc2773176 fix(tmux): bind nested prefix before unbind so an empty table cannot abort the chain
A user tmux config that leaves the prefix table empty makes
unbind-key -a -T prefix fail and drop later commands in the \; chain,
including send-prefix and remain-on-exit failed. Bind C-q send-prefix,
clear the table, then bind it again.

Also accept macOS ttys in the TUI switch-guard test without reading
/proc, and describe the client_pid hook escape as defensive on tmux 3.4.

Co-authored-by: DDT <1786035110@qq.com>
2026-10-23 09:08:48 +00:00
DDTandDDT daeea54495 fix(tmux): stop nested prefix keys and TUI Alt+digits from moving other clients
Docker and owned-remote tmux servers kept the stock prefix table, so Ctrl+Q
then )/(/s/w/L switch-client'd the inner client onto a sibling. Those create
chains now leave only C-q send-prefix.

codeman tui bound M-1..M-9 in the server-wide root table. The keys stay, but
switch-client runs only for the attaching client (tty plus a one-shot pid).
A browser tab's Esc-then-digit no longer jumps sessions, and a killed TUI
leaves a binding that matches no live client.

Co-authored-by: DDT <1786035110@qq.com>
2026-10-23 07:41:46 +00:00
Aamer Akhter 175e3b8320 fix(auth): keep the single-user login throttle at 10, widen only the multi-user pre-gate
Single-user mode has no per-username bucket, so its per-IP bucket is the
only limit on guesses against the one password, and behind a tunnel every
client shares 127.0.0.1. Put it back on AUTH_FAILURE_MAX (10).

AUTH_IP_FAILURE_MAX (50) now gates only the multi-user per-IP pre-gate,
which runs before the scrypt verify and so refuses even a correct password;
the per-username bucket still caps each account at 10.

Move the headroom test out of the single-user suite and add a multi-user
test that sprays wrong passwords across usernames (9 each) and expects 401
for the first 50, then 429 for the 51st and for a correct password.

Update the docs that still described 10 per IP for every mode, and resync
the plugin mirror of the codeman skill.
2026-10-22 21:21:49 -04:00
Aamer Akhter 32a1d544ab feat(terminal): link file paths that contain spaces as one path
Every path pattern ended a path at whitespace, so
"/mnt/c/OneDrive - Contoso/a.docx" linked "/mnt/c/OneDrive" plus a relative
"Contoso/a.docx", two links to the wrong files, in the terminal and the
response viewer alike.

findFilePathLinks() now takes a spaced path whole where the line bounds it:
the whole line is the path, or it follows a list marker and/or a short
"Label:". A relative spaced path needs the marker or label. The candidate
must read as one path (no earlier word ends on an extension, starts a new
"/" path or is an arrow). A spaced line that is one path but not linkable
(unknown root, "." or ".." segment) links nothing instead of a fragment.
2026-10-22 17:45:12 -04:00
Aamer Akhter 9f62da9ac3 feat(terminal): link URLs and paths that wrap across rows or sit in table cells
A CLI that word-wraps inside a markdown table cell or an indented paragraph
breaks a long URL or path short of the right edge and indents the rest, so
nothing is flagged isWrapped and no row is full width. Each row linked only
a fragment, and the fragment opened the wrong target.

- findTerminalUrls() (constants.js) is the URL grammar for the link provider:
  a ")" that closes a "(" inside the URL is kept, an unmatched one still ends
  it, so (https://...) and [text](https://...) stay clean.
- terminalUrlContinuationExtent() joins rows the CLI wrapped a URL across,
  only when the fragment ends on a wrap-break character, the next row is
  indented no further right than the URL started, and its first token looks
  like URL material. Table columns after the first fragment are left out.
- terminalLinkLine() widens terminalPathLine() to that extent; a URL that
  spans rows is emitted as one link per row, each opening the whole URL, so
  the indent and other columns between the pieces are not underlined.
- terminalCellWrappedPaths() follows an absolute path down its own table
  cell (space-separated or box-drawn), so two-column tables link both paths.
- A full-width table divider no longer counts as a hard-wrapped line.
- OSC 8 hyperlinks open through the same opener, web URLs only.
2026-10-22 17:44:46 -04:00
Aamer Akhter c6a5995ac6 feat(palette): choose the agent when starting a session from Cmd+K
The palette's "New session" row now shows the agent it will start and
lets Left/Right (or the row's arrows) switch it. The agents come from the
injected CLI catalog, filtered to enabled and installed entries, so no CLI
id list lives in the palette. The pick is a one-shot launch through run()
with a temporary _runMode swap: the Run button's mode is left unchanged.
2026-10-22 17:23:03 -04:00
Aamer Akhter 4f9274af3a fix(auth): require the request on findSessionOrFail
findSessionOrFail took `req` as optional, so the quiet default was the
unscoped lookup: a route that forgot to pass it compiled cleanly and, in
multi-user mode, returned another user's session. `req` is now required,
and a caller with no request in flight must use the explicit, greppable
findSessionUnscopedOrFail instead.

Every current caller in src/ already passes req (verified by grep and
tsc), so behaviour is unchanged; the new helper has no production
callers and exists so the next request-less caller does not make `req`
optional again.

Tests pin the unscoped helper's behaviour and assert, from the source
AST, that `req` is a required parameter (tsc does not typecheck test/).
2026-10-22 17:23:03 -04:00
Aamer Akhter ebb9e00de9 fix(auth): widen the per-IP login throttle to 50, keep the per-account lockout at 10
One AUTH_FAILURE_MAX (10 per 15 min) gated the single-user per-IP
bucket, the multi-user per-IP pre-gate, the per-username lockout and the
hook-secret bucket. Clients sharing one egress IP (NAT, a tunnel, an
automated client retrying) were throttled after 10 failures in total.

Add AUTH_IP_FAILURE_MAX = 50 and use it for the two per-IP login buckets
only. The per-username lockout and the hook-secret bucket keep
AUTH_FAILURE_MAX = 10, so guessing against any one account, or against
the hook secret, is no easier than before.
2026-10-22 17:23:03 -04:00
Devvyn 1cad4dd848 fix(cases): address review of the rename route
Only an admin's rename touches the server-wide settings.json, which is now
written before linked-cases.json with the registry restored on failure. The
Resume list follows a renamed folder, the owner's default session group is
carried over, remote sessions are never listed or stopped, errorCode maps
EXISTS and BUSY, and the unmigrated items are documented.
2026-10-23 04:15:46 +08:00
Devvyn 201603e57b feat(cases): rename in the app's own dialog instead of browser prompts 2026-10-23 04:12:37 +08:00
Devvyn ab0bc0aecd feat(cases): rename a case and its folder, with a lock and live-session handling 2026-10-23 04:12:37 +08:00
JD 84adb701d7 fix(terminal): load tmux history instead of paging Claude after a hollow tab switch
After a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, so an inline Claude session came back with an empty local buffer while tmux still held its scrollback. The hollow-buffer fallback then sent PageUp/PageDown to Claude, which moved the composer cursor instead of the transcript, until a page reload. Pre-existing on an unmodified build; every restart of the server showed it.

The paging fallback now stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0), dropping the gesture debt and any page keys already queued, so the gesture falls through to the local path, whose scroll-to-top pull fetches `full=1` and the gesture scrolls on from there. Zero and unknown counts keep paging (fullscreen Claude, codex, opencode, remote and Docker panes). The pull no longer inherits a downgrade refusal's 60 s latch for such a buffer, drops a completion that outlived a tab switch back to the same session (select generation, not only the session id), records no count from an error envelope (a JSON 5xx would otherwise forget it and hand the next gesture back to paging), and re-positions the local-echo overlay after its rewrite. The refresh path records its count only once it still owns the terminal, for the same reason. The `[scroll]` line prints `paneHistoryLines` so an unloaded buffer reads apart from a hollow one, and the invariant docs record the exception.

Measured with phone emulation after a restart: before, the second visit landed at baseY 0 and every swipe sent PageUp; after, the first swipe fetched the history once with nothing sent to Claude and the gesture scrolled 21 rows, with an unsent draft intact on the overlay.
2026-10-22 15:13:01 -04:00
JD 116961f3a8 fix(mobile): swipe through sessions in the order the strip draws them
nextSession/prevSession walk the canonical sessionOrder, but the case and state tab layouts, a sorted rail and named groups reorder the strip, so with any of them a swipe jumped to tabs that were not next to the current one on screen. The swipe now asks for the drawn order, read with the same walk the strip's arrow keys already use (factored out as _visibleTabRows); Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order. An active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere instead of falling back to a hidden session.
2026-10-22 15:11:21 -04:00
Aamer Akhter 536eca8f3d fix(sse): retry an EventSource connect attempt that never opens
An EventSource whose request never gets response headers stays in
CONNECTING without firing open or error, so the backoff never runs and
the staleness watchdog (which only judges a 'connected' stream) cannot
see it. Bound each attempt with a 15s connect timeout: if open has not
fired, close the attempt and retry through the same path onerror uses.

The timer is cleared on open, on failure and at the top of every
connectSSE(), and both the timeout and the failure handler ignore an
EventSource that has since been replaced.
2026-10-22 13:28:43 -04:00
Aamer Akhter 3489f6cc80 fix(tmux): send prompts that start with a hyphen literally
`send-keys -l <text>` keeps parsing options after `-l`, so text starting
with `-` was read as a flag ("unknown flag -x", "invalid flag --") and
never typed. End option parsing with `--` at every send-keys -l site in
sendInput() and sendInputToPane(), as set-buffer already does.
2026-10-22 13:26:04 -04:00
244 changed files with 29845 additions and 1348 deletions

No files matched your search

+2 -2
View File
@@ -9,8 +9,8 @@
{
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.44.0",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.46.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+75
View File
@@ -1,5 +1,80 @@
# aicodeman
## 1.46.0
### Minor Changes
- de1aab9: ### Thanks
- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710).
- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687).
- @opticon454 for the Fetch button and fetch interval in the Git status window (#714).
- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717).
**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency.
**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard.
**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before.
**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too.
**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link.
**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request.
**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it.
**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written.
**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual.
**Fixes.**
- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy.
- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session.
- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message.
- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865.
**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707).
**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill.
## 1.45.0
### Minor Changes
- ### Thanks
- @opticon454 for five PRs: renaming a case and its folder with the live sessions handled (#680), a resizable line between the tabs and the header toolbar (#689), opt-in GPU and CPU clock readings in the header (#692), and two Codex wheel fixes measured against the real CLI, a first notch that moves the transcript (#699, from #697) and a wheel that no longer moves a selection menu's choice (#698, from #696).
- @aakhter for five PRs: an agent picker on the Cmd+K New session row (#685), a login throttle that no longer locks a whole tunnel out after ten typos (#684), an SSE connect attempt that can no longer hang forever (#678), prompts that start with a hyphen (#677), and `req` made required on the session lookup helpers so an ownership check cannot be skipped by accident (#683).
- @JDProfresh for a phone swipe that follows the order the strip draws (#682) and an inline Claude tab whose history loads instead of paging the composer after a server restart (#681), both found and tested on an iPhone.
- @Randalix for the per-session agent inbox (#690), the shape agreed in Discussion #558.
- @Hugo-DDT for closing the last two ways a key binding could move a client onto another session (#688), and @christianhaberl for reporting both in #671.
- @johoja12 for remote hosts whose tmux lives in Homebrew or a per-user bin directory (#691), the follow-up promised in #645.
**Rename a case and its folder (#680).** The case manager has a Rename action. A case under `~/codeman-cases` is renamed as a name and folder; a linked case can rename only its name or its real folder too. Live sessions that have the folder open are listed and only stopped if you agree, the folder is locked while it moves so nothing starts in it half-moved, a folder another program holds open is reported instead of forced, and Claude Code's conversation history (including the Resume list), the case order, the last used case and the default session group follow the new name.
**A per-session agent inbox (#690).** Agents can leave each other messages without typing into anyone's pane: `codeman agent post <id> "<text>"`, `codeman agent inbox [--wait]` and `codeman agent ack <ids>`, over `POST`/`GET /api/sessions/:id/inbox`, `POST .../inbox/ack` and `GET /api/agent-inbox/summary`. A message stays until its receiver acknowledges it, so nothing is lost between a read and the ack, and `codeman agent ls` shows an INBOX column. Inboxes live in memory by default; **Keep agent inbox across restarts** (Settings, Cross-session features, off by default) writes them to a 0600 file in the data dir, kept for 12 hours.
**Pick the agent from Cmd+K (#685).** The palette's New session row shows the agent it will start, with arrows to change it (Left/Right while the row is highlighted). It starts on the Run button's mode, offers the same agents the Run menu offers for that case (a Docker case's container agents included), and launches once without changing the Run button.
**A resizable header toolbar (#689).** Drag the line between the session tabs and the header toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then (in the Compact style) their percentages as it narrows; double-click or Home resets it, the arrow keys move it, and the width is remembered on the device.
**GPU and CPU clock readings (#692, opt-in).** **GPU Stats** adds GPU load, graphics memory and the GPU clock after CPU and MEM (each only when the host reports it), and **CPU MHz** adds the live CPU clock. Both are in Settings, Header & Panels, off by default, and nothing is read unless the browser asks. NVIDIA is read through `nvidia-smi`, AMD through sysfs, and inside WSL any vendor through the Windows performance counters (no GPU clock there). The header also keeps polling its stats with no session open, so CPU and MEM no longer sit at `--`.
**Fixes.**
- **A tunnel no longer locks everyone out after ten typos (#684, multi-user mode).** Behind `tailscale serve` or a tunnel every login arrives from one address, so ten wrong passwords from anyone used to lock every user out for 15 minutes. The per-address limit is now 50, and each account allows ten failed guesses per address, both checked before the password is verified. Another address can no longer lock an account out, a burst of parallel guesses counts in full, and identical logins in flight share one check, so a reload with many tabs open is never refused. Single-user mode is unchanged.
- **The first wheel notch moves a Codex transcript (#699).** When a CLI does not track the mouse, Codeman turns the wheel into PageUp/PageDown, and it used to send nothing until half a screen of travel had built up: 5 notches in Chrome, 7 in Firefox. The first notch now pages at once and the notches after it repay it, so the page rate is unchanged.
- **The wheel no longer moves a Codex menu's selection (#698).** At "Implement this plan?" one notch down used to land on "No, stay in Plan mode" and one up on "Yes, implement this plan", because those menus use the page keys. The wheel is now ignored while such a menu shows, in the main pane and in tiles, and pages the transcript again once it closes.
- **History loads after a restart instead of paging Claude (#681).** After a server restart, a second visit to an inline Claude tab on a phone turned every swipe into PageUp/PageDown for Claude, so the composer jumped around and old output never showed. The swipe now loads tmux's history, as the first visit does.
- **A phone swipe follows the strip (#682).** With the case or state layout, a sorted rail or named groups, swiping now moves to the session drawn next to the current one, not the next one in the hidden canonical order. Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, matching the Alt+N badges.
- **Key bindings can no longer move a client onto another session (#688, fixes #671).** The tmux servers behind Docker cases and owned remote sessions keep only their send-prefix binding, as Codeman's own socket has since 1.44.0, and `codeman tui`'s Alt+1..9 switch only the TUI's own client: an Esc then a digit in a browser tab reaches its pane instead of switching it.
- **Remote tmux found in Homebrew or a per-user bin (#691).** Every remote tmux call (availability check, launch, attach, discovery, capture, cleanup) now runs on one widened, quoted PATH, so a host whose tmux works only in an interactive shell links and launches, home directories with spaces included.
- **Prompts that start with a hyphen (#677).** Text typed into a pane that began with `-` (`-x hello`, `--help`) was read by tmux as its own options and never arrived.
- **A connect attempt that never opens is retried (#678).** After a phone resumes on a VPN or tailnet, an event stream could sit in "connecting" forever with no error to trigger a retry. Each attempt now gets 15 seconds before it is closed and retried with the usual backoff.
**Fixes applied while landing.** A case rename that fails half way now puts `settings.json` back too and says where the folder is; it merges Claude Code's history into a folder a deleted case of the new name left behind instead of silently leaving it at the old name, refuses a dangling symlink at the new name before any session is stopped, refuses to move a folder while a session is still being created in it, and the dialog says plainly that stopping closes the sessions (#680). The inbox keeps the mail of sessions on the reboot-restore offer and of a detached session that comes back, prints message text with control characters escaped, and flushes after the server stops accepting posts (#690). Cmd+K's agent list follows the case's own availability rule, so a Docker case can start a CLI only its container has, and with a single choice the row keeps the Run button's mode (#685). The divider's stored width can no longer go negative and Escape no longer resets it (#689). With the toolbar narrowed, the GPU, VRAM and clock readings keep their labels, so they can still be told apart from CPU and MEM, and at Compact's rings-only step a clock with nothing left to show is hidden instead of leaving a gap (#689, #692). The stats route no longer waits on Windows' RAM probe, and a hung `nvidia-smi` can no longer freeze the header's CPU and MEM readings: the `nvidia-smi` and Windows RAM probes now give up after 1.5 s (#692). A second `codeman tui` on a longer pts number can no longer remove the first one's guard, and a client that fails the guard gets its key instead of losing it (#688). A test that had started passing vacuously after the SSE change checks the connect URL again (#678).
**Docs and tests.** `req` is now required on `findSessionOrFail` and `findPersistedSessionOrFail`, pinned by a test that also checks its exact type (#683). New sections for the agent inbox, the case rename, the header divider and the host sensors, the SSE connect timer next to the staleness watchdog, the login buckets in the security docs, and the remote PATH rule in the architecture notes.
## 1.44.0
### Minor Changes
+23 -15
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.44.0 (must match `package.json`)
**Version**: 1.46.0 (must match `package.json`)
## Project Overview
@@ -102,7 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so while a `codeman tui` attach is open its `root` keys reach browser tabs too, Alt+1..9 `switch-client` included (open follow-up). ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
@@ -129,9 +129,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
@@ -175,25 +175,27 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order (Alt+[ / Alt+] and Ctrl+Tab included), except the phone swipe, which walks the drawn order like the strip's arrow keys (`nextSession({ visual: true })` through `_stripSessionOrder()`), and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
@@ -203,6 +205,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
@@ -211,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
@@ -222,7 +226,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
@@ -230,7 +236,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
@@ -258,7 +266,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. ⚠️ The one sanctioned screen taller than its box is the phone keyboard row hold (#672, `keyboardHoldsRows`, default ON, inline renderers only via `capabilities.keyboardHold`): the PTY keeps its rows while the keyboard is up (each height change makes Claude's inline renderer drop transcript lines from tmux) and `--kb-clip` keeps the last content row at the bottom of the visible box. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
@@ -274,10 +282,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **CSRF / Origin** | Always-on cross-site Origin guard on state-changing requests. **A missing Origin is allowed** so curl/CLI and hooks keep working. ⚠️ The body parser keeps `text/plain` RAW; auto-JSON-parsing it enabled simple-request CSRF |
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead checks 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) BEFORE the password, so past either even a correct password from that IP waits (the account still logs in from another IP), plus 10 per username AFTER it (429 to wrong guesses from any IP, never to a correct password). QR and hook-secret have separate buckets, so neither can lock out login |
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
| **Tunnel** | Enabling a tunnel **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted). `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` acknowledges the BIND only and never satisfies this guard (#706: `install.sh` writes it for every passwordless LAN install). The authorization is decided ONCE per request, running or not, and required again at the `start()` site: the settings-file awaits between them let a concurrent stop land, and an unauthorized enable never writes `tunnelEnabled:true`. With no login the browser asks before every enable (welcome link, tunnel panel, App Settings save that turns it on) through ONE helper, `_confirmTunnelEnable()` (settings-ui.js), which reads `loginConfigured` from `GET /api/tunnel/status` (computed in the route by the guard's own test, never in `TunnelManager`); the 403 handler is the fallback when that read fails |
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
@@ -298,7 +306,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate.
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ A setting the server ACTS on must be synced, never a display key and never stripped by `saveAppSettings`: a per-device value never reaches the server, so a scheduler or watcher reads nobody's number (the Git fetch rows are pinned by `test/app-settings-structure.test.ts`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. ⚠️ Every write to the server's `settings.json` goes through `mutateSettingsFile()` (`src/web/settings-writer.ts`: one at a time, tmp+rename, and a file that does not parse is refused, never read as `{}`), and a route awaits it before answering: two unserialized plain `writeFile`s tore the file, and the `{}` fallback then erased every other setting. The one direct writer is config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that does not parse, which the writer refuses. Pinned by `test/settings-json-write-serialization.test.ts`.
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
@@ -307,7 +315,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
+32 -8
View File
@@ -460,7 +460,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → 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)
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. **Fetch** in that window's header runs `git fetch` for the repositories it lists, on your click or on an interval you turn on (**App Settings → Bottom bar**, off by default; **Session Options → Git** overrides both the switch and the interval per session). Sessions open on the same folder share one run and each sees its result. Apart from that fetch Codeman never fetches or changes the repo, so "behind" is as of the last fetch; not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
@@ -580,10 +580,12 @@ loginctl enable-linger $USER
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`)
2. On success → server issues a `codeman_session` cookie (24h TTL, auto-extends on activity)
3. Subsequent requests authenticate silently via cookie
4. 10 failed attempts per IP → 429 rate limit (15-minute decay)
4. 10 failed attempts per IP → 429 rate limit (15-minute decay). In multi-user mode the per-IP cap is 50, and each account allows 10 failed guesses per IP; past either, even a correct password from that IP waits for the decay, while the account still logs in from another IP
**Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions.
The tunnel Codeman manages (App Settings → System → Remote access) refuses to start without a login (`CODEMAN_PASSWORD` or multi-user mode) unless `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` acknowledges the exposure, or you accept the browser's confirm, which it asks before every enable while no login is set. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers only the network bind: it does not let the tunnel start.
</details>
### QR Code Authentication
@@ -670,7 +672,7 @@ By default Codeman launches sessions with `--dangerously-skip-permissions`, so t
- **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). 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
- **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. Multi-user mode allows 50 failures per IP instead, plus 10 failed guesses per account from one IP; both are checked before the password, so past either a correct password from that IP also waits for the decay, while the account still logs in from another IP. QR auth has its own separate limiter
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Agents & CLIs → Claude → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
### Always-on browser hardening (v0.9.5)
@@ -825,12 +827,14 @@ Those `DONE_<task>_<random>` strings are the skill's **split marker** trick, and
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. |
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. |
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. |
| [`reference/history.md`](skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. On demand. |
| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in: bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. On demand. |
Every recipe in there was verified against a live server, and the comments record the failure modes that were measured rather than guessed.
#### Two things worth knowing
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing.
- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing.
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
@@ -977,25 +981,44 @@ codeman tui 3 # attach to session 3 of that list
### `codeman agent` — session-to-session verbs in every CLI mode
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default)
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled)
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane
codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new
codeman agent ack <msg-id> # remove what you handled (read never removes)
codeman agent rm "$SID" # any session except this one
```
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd, and so does a cwd that is your home directory (Codeman's hooks would land in `~/.claude`), which needs an explicit `--working-dir ~`; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
### `codeman history read`: saved Codex conversations, offline
`codeman history read` reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else `~/.codex/sessions`) straight from disk: no server, no managed session, nothing written. Unlike `codeman agent` it works anywhere, including on a machine where Codeman is not running.
```bash
codeman history read --session-id 019fc604 # one conversation, by native id or 8+ character prefix
codeman history read --root-session-id <uuid> --topic auth # a conversation and every sub-agent or fork under it
codeman history read --file /path/to/rollout.jsonl --format text
```
Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a compressed `.jsonl.zst` rollout, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md).
### MCP server (`codeman mcp`): drive Codeman from any MCP client
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged.
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API. The MCP server adds no routes of its own, and auth and ownership are unchanged.
```bash
# Claude Code
@@ -1067,6 +1090,7 @@ REST over Fastify — **~230 handlers across 25 route modules**, plus an SSE str
| `GET` | `/api/sessions/:id/last-response` | The last answer as clean text, read from the transcript (claude, codex, deepseek) |
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
| `POST` / `GET` | `/api/sessions/:id/inbox` | Agent inbox: post `{text}` / read every pending message (`?wait=` blocks while nothing is new); `POST …/inbox/ack {ids}` removes them, receiver only |
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
@@ -1223,7 +1247,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
[![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input)
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length.
```bash
npm install xterm-zerolag-input
+9 -5
View File
@@ -581,10 +581,12 @@ loginctl enable-linger $USER
1. 首次请求 → 浏览器弹出 Basic Auth 提示(用户名:`admin` 或 `CODEMAN_USERNAME`)
2. 成功后 → 服务端签发 `codeman_session` cookie(24 小时 TTL,活动时自动延长)
3. 后续请求通过 cookie 静默认证
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)。多用户模式下每个 IP 的上限为 50 次,且同一 IP 对每个账户最多 10 次失败;超过任一上限后,来自该 IP 的正确密码也须等待衰减,但该账户仍可从其他 IP 登录
通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。
Codeman 自己管理的隧道(App Settings → System → Remote access)在没有登录(`CODEMAN_PASSWORD` 或多用户模式)时拒绝启动,除非用 `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` 或 `codeman web --allow-unauthenticated-tunnel` 确认这种暴露,或者你在浏览器的确认框里点了同意;没有登录时,每次开启前浏览器都会先问。`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` 只管网络绑定,并不能让隧道启动。
</details>
### 二维码认证
@@ -671,7 +673,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
- **默认仅环回** —— 服务器程序本身绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全(引导式安装器会询问网络访问方式,并替你配置绑定和密码)。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。多用户模式下每个 IP 改为 50 次,同一 IP 对每个账户另有 10 次失败上限;两者都在校验密码之前检查,超过任一上限后,来自该 IP 的正确密码也须等待衰减,但该账户仍可从其他 IP 登录。二维码认证有自己独立的限制器
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
### 始终开启的浏览器加固(v0.9.5)
@@ -826,12 +828,14 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 个完整流程:claude、DeepSeek Harness 与 shell 工作会话、扇出、盯住被卡住的工作会话、消息扇出。按需加载。 |
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | 完整端点表、错误码、各模式的信号表、容量限制。按需加载。 |
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | 通过 Claude Code 跨会话消息直接和 claude 工作会话对话。按需加载。 |
| [`reference/history.md`](skills/codeman/reference/history.md) | 用 `codeman history read` 离线读取已保存的 Codex 对话:选择器、过滤条件、分叉、预算。按需加载。 |
| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | 实验性功能,需要手动开启:用 `codeman agent inspect`、`stage` 和 `submit` 做有界的窗格检查和受保护的提交。按需加载。 |
里面的每一个配方都在真实服务器上验证过,注释记录的是实测出来而不是猜出来的失败模式。
#### 两件值得知道的事
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
@@ -993,7 +997,7 @@ codeman agent rm "$SID" # 除当前会话外
### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端:没有新路由,认证和归属规则不变。
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端。MCP 服务器自己不新增任何路由,认证和归属规则不变。
```bash
# Claude Code
@@ -1221,7 +1225,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
[![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input)
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。
```bash
npm install xterm-zerolag-input
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.44.0",
"version": "1.46.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.44.0",
"version": "1.46.0",
"transport": {
"type": "stdio"
},
+3
View File
@@ -19,6 +19,7 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/
export const BROWSER_TEST_GLOBS = [
'test/command-palette-agent-arrows.browser.test.ts',
'test/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
@@ -26,6 +27,8 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-search.browser.test.ts',
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/header-divider.browser.test.ts',
'test/header-host-sensors.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
'test/opencode-resize.test.ts',
+270 -4
View File
@@ -426,6 +426,13 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
turn that never started. Verified live; this is the most common silent failure on
this endpoint.
Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT`
with nothing written while an experimental guarded submit holds the session's
input lease, or input held behind it is still being written (a few milliseconds;
see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)).
Retry it. Untagged input is held instead and written in arrival order once the
lease is released.
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
a separate key, and the server re-presses Enter while the prompt is still visibly
@@ -589,12 +596,262 @@ request returns. The capture runs synchronous tmux calls on the server; the
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
## Experimental bounded agent inspection and guarded input
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
are labeled experimental, so the versioning policy's rule for experimental
surfaces applies (`docs/versioning-policy.md`, "Experimental agent
coordination"): they are not part of the stable contract. Their commands, flags, paths,
payloads and behavior may change or be removed in a minor or patch release.
Pin a compatible Codeman version when automating them. Authentication and
session ownership checks still apply.
**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced
`agentCoordinationEnabled` setting is off (the default; App Settings,
Cross-session features, **Experimental agent coordination**), with a message
naming it. The check runs before session lookup and body validation, and reads
the saved setting on every request, so turning it on needs no restart.
`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1).
These authenticated routes use the standard envelope and session ownership checks.
`/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before
any transcript, pane or receipt lookup.
| Endpoint | Parameters/body | Result |
| --- | --- | --- |
| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` |
| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter |
| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter |
Inspection initially supports native local Codex JSONL with strict conversation
attribution, using the session's effective `CODEX_HOME`, explicit resume identity
or unique managed originator. It never selects an arbitrary newest cwd match.
The initial read selects recent complete records; `omittedBeforeBytes` explicitly
labels its tail omission. Cursor reads consume only complete appended records,
keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON
escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution
separately caps at 8192 directory entries, eight directory levels, 8 MiB of
negative-filter prefix reads and 1 MiB of candidate metadata reads (including
final identity verification). The negative filter reads 1 KiB of each rollout
first and 4 KiB only when that window holds no complete `originator` field. Once
a rollout is attributed, later calls re-verify only that file (same file identity,
same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not
older than its own, for a second candidate such as a codex `/new`. A file that no
longer verifies falls back to one full walk; a verified file whose incremental scan
finds a second candidate or runs out of budget answers unattributed without one,
since the full walk reads a superset of that scan. So the caps bound the first call,
not every call.
An unexpected read error is `422 OPERATION_FAILED` (logged with the session id
and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories
are probed with a bounded past-cap probe before canonical reads and remain
unattributed. A full incremental page can be continued with its cursor; only a
first pending record too large for the budget reports an output-budget block.
Oversized/unfinished records report `blockedReason`; incremental
reads do not silently skip them. Other modes, remote/container sessions, and
unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`,
`cursor:null`; these snapshots can repeat evidence.
Attribution identifies a file, not an authoritative current-thread signal. A
configured launch resume and a new owned rollout conflict and force fallback.
An in-CLI `/resume` of an existing foreign-originator thread may leave no new
owned rollout; launch metadata cannot detect that switch. Check the returned
conversation identity and do not use these observations as proof of the active
thread, task completion or authorization.
Opaque server-side cursors bind caller, session, pane generation, conversation,
opened-file identity, byte offset and bounded content anchors. Terminal resizes
do not change this inspection scope. Each owner and session keeps its newest 8
cursors (a retry of the last call still works; older ones are retired), at most
1024 live cursors are retained in all, each for 15 minutes; restart, eviction or
expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign
cursors or loss of the attributed source return `409 CONFLICT`. No transcript
filesystem path is returned as cursor/identity metadata; evidence retains the
owner's native record content, which can itself contain paths.
Guarded input initially requires a local characterized Codex composer, one
unwrapped visible line, a recognized footer and a cursor at its exact end. The
composer glyph and footer are the CLI registry's
`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that
declares no `composerFooterLine` is refused.
Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts
at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing
whitespace (the existing mux transport trims it). Before typing, it checks the
actual captured pane width and rendered composer indentation, refusing any
message that would reach the right edge or wrap. Every declared Codex effort
word plus `default` is recognized. Its two-minute receipt stores
only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts),
never persisted prompt content.
Stage refuses, before typing anything, a message that would change what Enter
does rather than what it sends, from the registry's
`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message
starting with `/` (a slash command; codex runs it, or with its command list open
runs the highlighted one) or `!` (a shell command), checked after any leading
whitespace, and a message whose last word starts with `@` (file and mention
search) or `$` (skills; every `$` word, since whether codex opens that list
depends on its skill catalog). Those popups follow the caret, which stage leaves
at the end of the message, so a `@` or `$` word earlier in the message is
accepted. Send slash and shell commands through `POST .../input`
(`codeman agent send`). Independently, a frame with a suggestion popup in the
block directly above the composer (the registry's `composerPopupLine`) is refused
at stage and submit, since codex draws every popup there and leaves the footer
below the composer unchanged.
A receipt is issued only for a capture that shows exactly the message with the
caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive,
so after typing stage re-reads the pane every 50 ms for up to one second and
takes the first frame with the same pane generation, the input revision its own
write moved and a composer whose SHA-256 equals the message's. Each capture
reads the screen and the caret in one tmux invocation. A frame showing another
writer's input, a new pane lifecycle or a new geometry ends the wait at once.
Every refusal after the keys went out (typing failed or was ambiguous, the
pane changed, or no frame verified in time) is a `409` that says the message
may already be in the composer: inspect the pane and follow the stranded-draft
recovery (Ctrl+U) in the skill reference before staging again.
While a guarded submit holds the input lease (the few milliseconds of its
conditional Enter), input that reaches the session is held, not refused, and is
written in arrival order as soon as the lease is released, whatever the submit's
outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged
only once it is written, so a frame typed during the lease lands before the one
typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval
answers and Codeman's own managed writes. A held write that is still being sent
holds everything behind it, with one exception: when the hold queue is full (256
inputs or 1 MiB), a managed write (`writeViaMux`, which `POST .../input` uses for a plain
prompt or `useMux`) is not held and that route falls back to a direct write, which
is refused during the lease itself but can land ahead of input still being written
after it. A tagged `POST /api/v1/sessions/:id/input` (one
carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit
is in progress on this session; nothing was written") for that span, the lease
plus the held input's drain, rather than a `200` for bytes that were never
written; it keeps its seq, so the browser's durable queue, whose POST fallback
sends one frame at a time and keeps it on a `409`, delivers it on the next retry.
Untagged input to that route is held like a WebSocket frame, because its senders
(the phone accessory keys, the Stop button) never read the answer. A lease holds
at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and
nothing later from it is applied, so the browser reconnects and resends every
unacknowledged frame in order, and `send-key` and an untagged raw input answer
`409 CONFLICT`.
Direct auxiliary key operations participate in the same pending-write and input
lease checks. Complete allowlisted focus/mouse reports and empty writes preserve
input revision; mixed or unknown sequences remain keyboard-bearing. Exact
composer/caret checks also reject a mouse-moved caret.
Submit consumes an owned receipt before awaited delivery, verifies the caller,
confirmation hash and expiry, then waits until at least 250 ms have passed since
the staged keys went out (codex reads keys typed in one burst as a paste and, for
120 ms after one, turns Enter into a newline instead of a submit), then verifies
lifecycle/input/geometry revisions and exact composer/caret,
then uses a session-level conditional Enter dispatch with a temporary input lease.
Competing Codeman writes cannot enter during that dispatch. Managed restarts and
intervening browser/API/auxiliary input invalidate receipts independently of pid
reuse or restored visible text. Direct tmux edits remain outside Codeman's
serialization. Failed, ambiguous, expired, changed or repeated submits return
`409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its
error recovery automatically replays Enter. Success acknowledges delivery of one
Enter, not native acceptance or task completion. Invalid schema input returns
`400 INVALID_INPUT`.
## The `codeman agent` CLI (client over these endpoints)
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`, and adds no route for the other verbs: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
## Agent inbox
A per-session mailbox: another agent (or the user) posts a message, and the
receiving agent reads it when it chooses to. Nothing is typed into the
receiver's pane, which is the difference from `POST …/input`: a post does not
start or interrupt a turn, and the text may span several lines. Design and the
reasons behind each rule: [discussion #558](https://github.com/Ark0N/Codeman/discussions/558).
| Method | Route | Body / query | Answer (`data`) |
|---|---|---|---|
| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` |
| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=<ms>` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` |
| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` |
| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` |
A message is `{ id, from, text, createdAt, seen }`. `from` is a session id or
`null` (the user); `seen` says whether the receiver had read it before this read.
**Who is calling.** The server resolves the caller from the
`X-Codeman-Parent-Session` header, the one `codeman agent` sends on every
request, with the same resolver as session lineage: a live session the caller
can see, with the same owner as the receiving session, matched by exact id or a
unique prefix of at least 8 characters. Anything else counts as no session.
The body cannot name a sender (`InboxPostSchema` is strict). In multi-user mode
that means a caller cannot claim to be another owner's session. Admins are the
exception, as everywhere else: an admin can see every session, so an admin's
request can act as any session of the receiver's owner. In single-user
mode every local process holds the same credentials, so the header is a hint the
client gives about itself, not proof.
**Read until ack.** A read returns every pending message, oldest first, and
removes nothing. Only `POST …/ack` removes, by id. Acking an id that is already
gone is a success, so a retry after a lost response does not fail.
**Seen is the receiver's.** A read marks messages seen only when the resolved
caller is the receiving session itself (`receiver: true`). Any other read, a
coordinator checking a worker or a request without the header, is a peek and
changes nothing. `ack` from anyone but the receiver is `403 FORBIDDEN`.
**`?wait=`** blocks while the inbox holds nothing **unseen**. A message that
was read but not acked does not end the wait, so a receiver that keeps one
pending does not spin. The value is clamped to the agent-wait bounds like
`timeout` on `/wait`, a timeout is a `200` with `wait.timedOut: true`, and the
waiter counts against the same per-session, per-owner and total caps (`409
SESSION_BUSY` / `429 RATE_LIMITED`). `wait` has the shape of the other wait
results plus `posted` (a post released it). A session deleted during the wait
answers `wait.ended: true` with an empty inbox. A detach or a server shutdown
ends the wait with `ended: true` as well, so check the session list before
treating the session as gone (`codeman agent inbox --wait` does: exit 3 only
when the list lacks it, exit 2 otherwise). A client that hangs up during the wait
marks nothing seen. A **peek** with `?wait=` returns at once for as long as the
receiver has unseen mail, since only the receiver's read clears it; to watch
whether a worker has read its mail, poll the summary instead.
**Limits.** 200 messages, 16384 bytes (UTF-8) per message, 512 KiB of text per
inbox. A message over the per-message cap is `400 INVALID_INPUT`. A post that
would cross either inbox cap is `409 CONFLICT` and nothing is stored; the oldest
message is never dropped to make room. Messages expire 12 hours after their
`createdAt`, read or not.
**Summary.** `GET /api/v1/agent-inbox/summary` lists every session the caller
can see that has mail or a parked receiver: `pending`, `unseen`,
`oldestUnseenAt` (epoch ms, `null` when nothing is unseen) and `parked` (the
receiver itself is waiting on `?wait=` right now). Counts only, never text. It
is not part of the session object, so a post does not broadcast
`session:updated`.
**Lifetime.** An inbox is deleted with its session (`DELETE
/api/v1/sessions/:id` and every other delete path), not when a session is
detached. By default inboxes live in memory and a server restart empties them.
The synced setting `agentInboxPersistEnabled` (default off; Settings → Cross-session features)
writes them to `agent-inbox.json` in the data dir (mode `0600`, tmp file +
rename) and restores them at boot. Inboxes of sessions that did not come back
are pruned only after the session restore succeeded; a session on the
reboot-restore offer keeps its inbox, so a rebuild finds its mail. Switching the setting off
deletes the file and keeps what is in memory. The file is never read by
`/api/search`, and the file viewer refuses it.
```bash
# coordinator: leave a task for a worker
curl -s -X POST "$API/api/v1/sessions/$WORKER/inbox" \
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$(cat task.md)" '{text: $t}')"
# worker: wait for new mail, handle it, then ack by id
curl -s "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox?wait=300000" \
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" | jq '.data.messages'
curl -s -X POST "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox/ack" \
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \
-d '{"ids":["<id>"]}'
```
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
A `multipart/form-data` body with one `image` part. The file is written into the
@@ -1060,7 +1317,7 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
## Git status
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` (the panel's Fetch button is a separate route, below). The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
@@ -1083,6 +1340,12 @@ Both routes accept two optional query parameters, which the UI sends from its pe
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run.
`data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought.
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
## Git worktrees of a case
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
@@ -1121,12 +1384,13 @@ A local case whose folder is the top of a git repository can keep **worktrees**
## Settings
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected.
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
| Method | Path | Body | Notes |
| ------ | ----------------------------- | ------------------------------------ | ----- |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. |
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. |
@@ -1134,6 +1398,8 @@ A local case whose folder is the top of a git repository can keep **worktrees**
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
### System operations
The same multi-user rule covers the operations that act on the whole server. For a non-admin:
+136 -16
View File
@@ -18,7 +18,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### The prefix table on Codeman's socket holds only send-prefix
**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to `<prefix> send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C-<letter> detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide, so what the TUI binds for its attach applies to browser tabs as well: F1 and `C-d` detach a tab's client, and **`M-1`..`M-9` (`switch-client -t <session>`) move it onto another session**, the very thing this reset exists to stop. The browser keeps a typed Alt+digit out of the terminal (`terminal-ui.js` swallows it for its own tab switching), but Esc followed by a digit within tmux's `escape-time` (500 ms on tmux 3.4) arrives as the same `M-<digit>`. This lasts for the length of the attach, and indefinitely after a TUI that is killed before `restore()` runs. Not fixed here; scoping those bindings to the TUI's own client is the open follow-up. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`.
**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to `<prefix> send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. The same reduction is applied when Codeman creates the nested tmux behind a Docker case (`-L codeman-docker`) or an owned remote session (`-L codeman-remote`). Those servers set `prefix C-q` and used to keep tmux's stock prefix table, so inside the pane Ctrl+Q then `)` `(` `s` `w` or `L` ran `switch-client` / `choose-tree` on the inner server and moved its client onto a sibling session. `nestedPrefixTableResetCommands()` (bind `C-q send-prefix`, then `unbind-key -a -T prefix`, then bind it again, so a user config that left the prefix table empty cannot make `unbind-key -a` abort the `\;` chain) is appended to both create chains, before `remain-on-exit failed` on the remote chain so a remote tmux that rejects `failed` cannot drop it. Those sockets are dedicated to Codeman, so the server-global table is the right scope. A non-owned remote attach joins another Codeman's `-L codeman` and does not run it. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C-<letter> detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide. F1 and the held-Ctrl `C-d` still detach every client for the length of an attach (a way out, not a session move). **`M-1`..`M-9` do not move other clients.** `bindSwitchKey` binds them to `if-shell -F` (`tuiSwitchGuard`) so `switch-client` runs only when `#{client_tty}` is the TUI's terminal and `#{client_pid}` equals `@codeman-tui-pid`. A one-shot `client-attached[90]` hook (`tuiSwitchAttachHook`) sets that option as the TUI's client attaches, then removes itself. It is armed as the handoff's last tmux call before `spawnSync`, after the keys and the per-session bars, so only a TUI killed during the attach's own startup can leave that hook armed for its tty. A browser tab does not match, including Esc followed by a digit within `escape-time` (500 ms on tmux 3.4), which arrives as the same `M-<digit>`. A client that fails the guard gets the key back: the `if-shell` else argument is `send-keys M-<digit>`, which writes Esc and the digit into that client's own pane, so a vim `<Esc>2j` in a browser tab is not eaten by the root binding. If the TUI is killed before `restore()`, the binding can remain, but the stored pid is the dead client and the hook will not re-arm a pty that reuses the number, so the key never switches and every client gets it in its pane through the else branch. `restore()` removes the keys only while they still name this tty, and the next attach that claims the strip releases any of these bindings first. A second TUI attach replaces the keys (one binding per key). Escape-time on Codeman's socket stays at tmux's default: lowering it would change Esc delivery for every pane, and the guard already refuses other clients. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client, including the nested `C-q` table), `test/tui/tui-switch-guard.test.ts` (the guarded Alt keys against a second client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`.
### Reverse-proxy base path
@@ -53,7 +53,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here.
⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder — one-shot AND streaming — stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`.
⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder (one-shot AND streaming) before 24.22 and 26.10 (nodejs/node#65865) stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`.
⚠️ **This is also what makes dsh the one non-claude mode the `codeman` agent skill drives like claude** (`skills/codeman/preamble.sh`, preamble 1.20.0): with a real end-of-turn signal AND a real transcript, `spawn_workers alpha beta:deepseek` is a mixed fleet in one call and `sendwait`/`last_text` need no per-mode variant. Two traps are handled in the preamble rather than left to the agent. **Readiness is not the stop signal**: the harness reports `idle` at BOOT roughly 300 ms before its composer paints (measured 2.26 s vs 2.56 s after spawn, twice), so a send-and-wait fired straight after `quick-start` resolves on the boot edge, reports a turn that never ran, and strands the prompt in a pane that was not yet accepting input — `spawn_worker`'s dsh branch gates on the composer glyph (`❯`, overridable via `DSH_READY_MARK`) instead, after which the boot edge is spent and unobservable. And `sendwait` asks for `wait:"stop,exit"` rather than the `wait:true` default set, because that set also carries `idle`, which for any external CLI is inferred from output stabilization: on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn that had three minutes left to run. The skill also sends `deepSeekConfig.permissionMode: 'danger-full-access'` for its own workers, matching what the Run button sends, because the harness default still asks and a worker parked on an approval row cannot finish a fan-out (the multi-user clamp still applies).
@@ -89,6 +89,8 @@ File reads in a remote case are the second ssh surface (see Remote SSH cases bel
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids).
Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
@@ -109,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different
⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug.
⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression.
@@ -203,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
@@ -221,7 +223,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
**The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture, and for a remote SSH session, whose local pane hosts the remote tmux client and always reports 0 while `full=1` reads the remote history), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the notice decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The one place `alternate_on` decides is a shell's AUTOMATIC load (`alt=recording`, see Full-scrollback replay): there the question is whether the capture holds the scrollback a nested tmux client keeps in its byte recording, not whether a pull can return more. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`.
**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite).
**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript, and the same memory is what stops it: a buffer a tail replay left at `baseY === 0` while the pane reports scrollback is unloaded, not hollow, so the gesture scrolls locally and the scroll-to-top pull (`_maybeRefetchFullHistory`) fetches `full=1` (see the hollow-pane rule under wheel forwarding). That pull skips the 60 s useless-pull latch for such a buffer (the latch's premise, a browser holding more than tmux, is gone) and drops a completion whose select generation moved during the fetch, since the session-id check alone cannot see a switch that came back (A to B to A); the refresh path records its count only past its owner check for the same reason, and no caller (the switch, the refresh, the pull) records an error envelope. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite).
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass without `alt=recording` (every `useFullHistory` pass; only shell loads send it) is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
@@ -243,9 +245,9 @@ Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Fi
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still paLine truncated
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow smalLine truncated
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|page-keys-dialog-open|page-keys-menu-open|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts.
@@ -343,8 +345,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it.
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after every launch keeps the toolbar's own case (`_reloadCasesAfterRun`): the no-argument reload would re-select the server's `lastUsedCase`, which a "+" launching through quick-start moves to the tab's case while the toolbar stays put, so a later worktree launch, plain Run or group "New session" would carry the toolbar there. ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it.
- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown).
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
@@ -407,7 +409,11 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
@@ -688,6 +694,20 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
### Agent inbox
**Agent inbox** (discussion #558): a per-session mailbox one agent posts to and another reads, so a coordinator can hand a worker text without typing into its pane. Store `web/agent-inbox.ts` (pure, `sessionWaits`-style singleton, no IO), routes `routes/inbox-routes.ts`, CLI verbs `codeman agent post|inbox|ack`, opt-in snapshot `web/agent-inbox-persistence.ts`. Nothing in it writes to a pane, and it is kept out of `toState()` so a post does not fan out a `session:updated` to every browser; the counts live behind `GET /api/agent-inbox/summary` (owner-scoped, counts only, never text), which `codeman agent ls` merges into its INBOX column.
⚠️ **Read until ack.** A read removes nothing; the same messages come back until the receiver acks them BY ID (`POST …/inbox/ack`, idempotent: an id already gone is not an error). Acking by id rather than "everything I read" is what keeps a message posted between the read and the ack from being lost.
⚠️ **Seen and ack belong to the receiver.** The caller is whatever `resolveParentSessionId()` makes of `X-Codeman-Parent-Session` (live session, same owner as the receiver), never a body field (`InboxPostSchema` is strict). Only a read whose caller IS the receiver marks messages seen; anyone else's read is a peek. An ack from anyone else is a 403, not a silent no-op, so a coordinator never believes it cleared a worker's mail. In multi-user mode this stops a non-admin caller from naming another owner's session (an admin passes `canAccessOwned` for every session, so an admin can act as any session of the receiver's owner); in single-user mode every local process holds the same credentials, so the header is the client's own claim about itself, which is all the seen/peek split needs.
⚠️ **`?wait=` parks while nothing is UNSEEN**, not while nothing is pending: a receiver holding a seen-but-unacked message would otherwise get an immediate answer on every loop and spin. Inbox waiters are the registry's third waiter kind (`waitForInbox`/`notifyInbox`), so they count against the same per-session/owner/total caps and are ended by `cancelAll` like signal and output waiters. `notifyInbox` runs AFTER the store, so a woken reader finds the message. `parked` in the summary counts receiver waiters only.
⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock.
⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row, except a record whose tmux session the multiplexer still lists (`mux.getSession`): a detach keeps that pane, and the next boot re-adopts it under the same id even without a `state.json` record. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`.
### Voice dictation via Claude
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses.
@@ -734,7 +754,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again.
⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened.
⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a file that does not parse: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened.
⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock.
@@ -895,7 +915,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
⚠️ **The arrow-key walk is the one place that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the thing the user steps with their eyes follows the paint.
⚠️ **The arrow-key walk and the phone swipe are the two places that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). That walk is `_visibleTabRows()`, and the phone swipe shares it: `SwipeHandler` calls `nextSession({ visual: true })` / `prevSession({ visual: true })`, which step through `_stripSessionOrder()` (the grouped tree's session rows via `_tabTreeItems()`, otherwise `_visibleTabRows()`, web tabs skipped), so a swipe lands on the tab drawn next to the current one in the case and state layouts, a sorted rail and named groups; Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep `sessionOrder`. An active session the list does not draw steps in at the first row (next) or the last (previous), and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the things the user steps with their eyes (the arrow keys and the swipe) follow the paint.
### Split-pane sessions
@@ -1009,6 +1029,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
⚠️ **The opt-in tab "+" is a third icon in that cluster**: with **New Session Button on Tabs** on (`showTabNewButton`, per device, off by default; `html.tabs-show-new`) the active phone tab shows a 28px "+" between the gear and close, and `html.tabs-show-new .session-tab.active .tab-name` grows the reserve to 68px (gear 32 + "+" 28 + close 20 less close's 2px margin needs a 64px label, 66 with the test's rounding allowance), still under the 80px `max-width`. Growing the reserve while the setting is on is fine; shrinking the base one never is. The same test recomputes it with the "+" counted.
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
### Tab layouts (by state, by case, ledger)
@@ -1029,7 +1051,7 @@ With `classic` nothing is left behind: no headings, no breaks, no inline order,
### Header stats styles
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. The opt-in host readings after MEM (GPU, VRAM, GPU MHz, CPU MHz: `.stat-item` elements that start `hidden` and are shown by `_updateHostSensors` in panels-ui.js) rely on `html[data-header-stats] .header-system-stats .stat-item[hidden]` in styles.css outranking the Tiles style's own `display` on `.stat-item`; keep that selector's specificity above any style rule that sets `display` on a stat item, or the hidden placeholders (`--%`) reappear. Those four carry `.stat-item--host`, which the header divider's density rule (`data-header-density`) excludes from its label hide: their values read like CPU's and MEM's, so the label is the only thing naming them. At Compact + `tiny` (rings only, values hidden) a reading with no ring is hidden outright rather than left an empty flex item: `.stat-item--ringless`, static on the two clocks and toggled by `_updateHostSensors` on VRAM while its total (and so its ring) is unknown. Tests: `test/header-host-sensors.browser.test.ts` (browser suite, not in the gate).
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
@@ -1131,9 +1153,13 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
⚠️ **The connect attempt has its own deadline, because the watchdog cannot see it.** An `EventSource` whose request never gets response headers (a phone resumed on a VPN or tailnet, a proxy that accepted the socket and went silent) stays `CONNECTING` without firing `open` or `error`, so `onerror` never schedules a retry and the status is never `connected` for `computeSseStale()` to judge: the page sat on "connecting" until a reload. `connectSSE()` therefore bounds each attempt by `SSE_CONNECT_TIMEOUT_MS` (15s, exported as `CodemanSseStale.CONNECT_TIMEOUT_MS` in constants.js; read once into `_sseConnectTimeoutMs`) and, if `open` has not fired, retries through `_handleSseConnectionFailure()`, the former `onerror` body, so a hung attempt gets the same backoff, status change and "Retrying in Ns" countdown as an errored one. ⚠️ The timer is cleared in exactly three places: at the top of `connectSSE()` **before** the offline early return (every path into `connectSSE()` supersedes the previous attempt; removing that clear fails the offline case in the test), on `open`, and in `_handleSseConnectionFailure()`. ⚠️ Both the timeout callback and `_handleSseConnectionFailure()` return early for an `EventSource` that has since been replaced, so a late error and the timeout never count one failure twice, and the timeout also skips a connection whose `readyState` is already `OPEN` before its `open` callback ran. ⚠️ The window `offline` handler deliberately leaves the timer running, since `navigator.onLine` can report offline on a working network; only a `connectSSE()` made while offline cancels it. Tests: `test/sse-connect-timeout.test.ts` (the real constants.js + app.js in a `vm` context with a fake `EventSource` and fake timers).
### PTY and browser terminal geometry
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it).Line truncated
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px Line truncated
**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes.
### Terminal resilience: replay clears, renderer liveness, fetch deadlines
@@ -1163,8 +1189,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter |
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
@@ -1242,3 +1268,97 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers
⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see.
⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
## Agent inspection and guarded submission
These three operations are experimental across both CLI and HTTP, including the
`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule
in `docs/versioning-policy.md`, so do not infer stability from the URL prefix.
⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming
`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session
features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The
gate runs first, before session lookup or body validation, and reads
`settings.json` fresh on every request, so a toggle applies without a restart.
`codeman agent inspect|stage|submit` pass the 403 through verbatim.
`agent-session-routes.ts` is registered by `WebServer` with the other route
modules. Ownership is checked before transcript/pane/receipt lookup. Inspection
uses strict native metadata attribution, bounded traversal, separate prefix and
candidate-metadata budgets, bounded path probes before canonical reads, and
complete UTF8 JSONL records. A full page advances its cursor without claiming a
blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane
geometry is excluded so changing devices does not invalidate transcript paging.
Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8,
under the global 1,024), so one polling agent retires its own oldest cursors instead
of evicting every other caller's from the shared FIFO.
⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store
walk per call. The first call walks (negative-filter prefixes read 1 KiB first,
4 KiB only when that window holds no complete `originator` field; non-UUID file
names are skipped unread), then the attributed `{path, dev:ino}` and its strict
metadata are remembered per session (`attributionMemo`, bounded LRU). A later call
re-verifies only that file's head, then scans just the date directories not older
than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is
never pruned) for a second candidate, which is how a codex `/new` or a new owned
rollout beside a configured resume is still caught. A head that no longer verifies
drops the memo and falls back to one full walk; a verified head whose incremental
scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it
and answers unattributed without one, because the full walk reads a superset of that
scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422
OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and
error code only, never the path.
Staging requires a fresh, known composer/caret capture. The composer glyph and
footer are registry data (`capabilities.workDetect.promptGlyph` and
`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI
declaring `composerFooterLine` takes guarded input (codex today). Codex's footer
words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available
columns are calculated from actual captured geometry and the rendered prefix,
including indentation; reaching the last column or wrapping is refused. A message
starting with a `composerCommandPrefixes` character or whose last word starts with a
`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a
frame with a `composerPopupLine` row in the block just above the composer is refused at
stage and submit: codex draws its popups there and keeps its footer, so the footer
alone read `› /stat` with the command list open as idle, and Enter ran `/status`.
⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS`
50) for the first frame with the same generation, `revision === before + 1` and
`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys
and the first capture is often from before them; another writer or a lifecycle change
ends the wait at once, and every refusal after typing says the message may be in the
composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call
(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to
the two calls only for output it cannot split; a combined call that THREW goes to the
capture's catch and is never rerun (a hung tmux would block the event loop for a second
timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives
while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`,
`_inGuardedTurn()`), and written in arrival order when the lease is released: the
browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by
N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written;
a held async write (mux write, named key) holds everything behind it until it settles
(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with
`1013` and applies nothing later from it, so the browser resends in order. `POST
.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain,
since its browser drainer is serialized and retries; untagged input there (the phone
accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the
answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write
by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue
it again behind later input and wait on itself). `write()` refuses during the lease
only, since the drain writes held input through it, so a direct `write()` caller such
as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining.
⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its
checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and
an Enter within 120 ms of one becomes a newline while submit would report it
delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and
bounded ephemeral identities only, expire, and are consumed before awaited
submission, including failures.
Submission additionally binds input and geometry revisions and rechecks the
exact composer/caret at the conditional Enter boundary. The route must retain
the Session snapshot's staging generation rather than replacing it with the
inspection generation. Managed input and auxiliary named keys participate in the
same pending/lease boundary: they wait for the lease, and only a full hold queue
refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are
not edits. Never retry ambiguous Enter delivery or clear an unverified composer.
The ordinary SubmitVerifier uses the shared write boundary, so each re-press
updates `lastSubmitAt` just like any other Enter.
+33 -3
View File
@@ -47,9 +47,12 @@ interface CliEntry {
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?,
// blockedLine?, blockedLines? }
// blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?,
// composerCommandPrefixes?, composerCompletionSigils? }
// (how this CLI's pane shows work, work it started in the background, a turn
// that ended waiting for workers it will resume from, and a dialog it is blocked on)
// that ended waiting for workers it will resume from, a dialog it is blocked on,
// the rows around its idle composer that guarded input relies on, and what a
// draft can trigger)
// .modelDetect?: { screenLine, screenLines? }
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
@@ -63,7 +66,7 @@ interface CliEntry {
### Regexes that come from config
Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
@@ -124,6 +127,33 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f
costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a
pattern, make sure its row is one the agent cannot write.
### The idle composer guarded input reads (`composerFooterLine`)
The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through
`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture,
that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the
exact end of its text. `composerFooterLine` is the second half of that proof: every
non-blank row below the composer (at most three) must match it, one row at a time. A CLI
that declares none never takes guarded input; only codex declares one, built from the same
effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a
codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the
foot of the screen, so a pattern must anchor on chrome only that CLI draws there.
Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup
(its slash-command list, file search, skill and mention lists) ABOVE the composer and
leaves the footer below it alone, so the footer check reads `› /stat` with the command
list open as an idle draft, and Enter would run the highlighted `/status`.
`composerPopupLine` matches a popup row; it is tested against the contiguous block of
non-blank rows directly above the composer (past at most three blank rows, at most twelve
rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists
the characters that, first in a draft, make Enter run a command (codex: `/!`), and
`composerCompletionSigils` the ones that, starting the word under the caret, open a popup
(codex: `@$`); stage refuses such a message before typing, the sigils only on the last
word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's
values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search
popup with results shows bare path rows no pattern can tell from prose, which is why the
trigger refusal is there as well as the frame check.
### Dialogs a CLI blocks on (`blockedLine`)
`blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to
+5 -3
View File
@@ -235,9 +235,11 @@ worker's first answer reads as an answer. Three things about the file shaped the
reader (`src/deepseek-transcript.ts`):
- **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all
of them, Node's `zlib` zstd decoder stops at the first: a real 56-line
transcript came back as 1 line. The reader walks frame headers itself. On a Node
older than 22.15 (no zstd at all) the mode falls back to the pane, as before.
of them, Node's `zlib` zstd decoder before 24.22 and 26.10 stops at the first:
a real 56-line transcript came back as 1 line. The reader walks frame headers
itself, which stays correct on a Node that decodes every frame
(nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode
falls back to the pane, as before.
- **Not every `user/message` is the user.** Each turn also records a
plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a
prompt.
+1 -1
View File
@@ -105,7 +105,7 @@ Keep the existing single-user branch untouched. Add a parallel multi-user branch
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so one flaky user behind a NAT cannot lock out the rest. As built, the per-username bucket is checked after the verify: it answers 429 to failed guesses once full and never denies a correct password, so it does not cap verified guesses. What caps them is a per-(username, IP) bucket checked before the verify (10 per pair), beside a per-IP pre-gate of 50 (#684).
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
+12 -2
View File
@@ -94,7 +94,10 @@ ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest'
set -t codeman-ssh-<id8> window-size latest \; \
bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
bind-key -T prefix C-q send-prefix \; \
set -t codeman-ssh-<id8> remain-on-exit failed'
```
Key points:
@@ -108,7 +111,14 @@ Key points:
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
tmux server's other sessions are untouched (#145 hardening). Note the
tmux server's other sessions are untouched (#145 hardening). The prefix
*table* is server-global, so the create chain reduces it to `C-q send-prefix`
(bind `send-prefix`, then `unbind-key -a -T prefix`, then bind it again). The
leading bind keeps an empty prefix table from aborting the chain:
`unbind-key -a` fails with "table prefix doesn't exist" and tmux drops every
later command, including `remain-on-exit failed`. Without that reduction, Ctrl+Q then `)` moves
the inner client onto a sibling session. A non-owned attach does not do this:
it joins the remote Codeman's own socket. Note the
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
socket** — they join sessions the remote's own Codeman manages, while owned
durable launches live on `-L codeman-remote`.
+31 -5
View File
@@ -105,7 +105,9 @@ The warning points at three ways to secure the instance:
2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`).
3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`
— explicitly accept the risk (downgrades the warning to a one‑line note). This
flag is **only** an acknowledgement; it does not change reachability.
flag is **only** an acknowledgement; it does not change reachability. It covers
the bind and nothing else: it does **not** let the managed Cloudflare tunnel
start without a password, which has its own opt‑in (§2, the tunnel caveat).
`CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback
address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the
@@ -149,7 +151,7 @@ by presence in a server‑side map, so they cannot be forged offline.
### Rate limiting / lockout recovery
Failed auth is tracked **per IP**: 10 failures → `429`, with a 15‑minute decay.
Failed logins are tracked **per IP**: 10 failures (`AUTH_FAILURE_MAX`) → `429`, with a 15‑minute decay. Multi-user mode replaces that with three buckets, all with the same decay: a wider per-IP **pre-gate** of 50 failures (`AUTH_IP_FAILURE_MAX`), checked before the scrypt verify, which bounds scrypt cost from one source and tolerates many users behind one egress IP; a **per-(username, IP)** gate of 10 failures (`AUTH_FAILURE_MAX`), also checked before the verify, which is what caps verified guesses against one account from one source; and a **per-username** bucket, checked after the verify, that answers `429` to failed guesses from the 10th on, from any IP (the count is incremented before the check). The last two exist in multi-user mode only. So the wider per-IP cap does not widen guessing against any one account from one IP, and other IPs cannot lock an account out.
The QR path has its own separate limiter.
The lockout check sits **after** the cookie/credential checks (step 4, not first).
@@ -158,6 +160,22 @@ immediately** even while an attacker is hammering the same IP — important beca
all traffic through a tunnel shares one source IP (loopback). Wrong credentials
are still counted and still hit the `429` at the threshold, so brute‑force
protection is unchanged.
The two multi-user pre-verify gates are the exception: once the per-IP pre-gate
or an account's (username, IP) gate trips, even a correct password from that IP
gets `429` until the decay (for the pair gate, only for that account; the same
account from another IP still logs in), which is why the per-IP cap is the
widest. The per-username bucket stays after the verify on purpose: checked
first, a username-wide lockout let throwaway IPs keep a known account (admin
included) locked out even with the right password.
Both pre-verify gates count a request the moment it passes them, before its
verify starts, so a burst of concurrent attempts cannot all slip through on one
stale count; a correct password then clears its slot with the rest, and a
failure is not counted a second time. A verify still in flight therefore holds a
slot, even one carrying the right password, but identical credentials from the
same IP that arrive during it share that verify, its slot and its outcome, and
skip both gates: they add no guess and no scrypt work. So a browser replaying its
cached login on many parallel requests after a restart (cookie sessions live in
memory) is not refused by the slots its own password filled.
---
@@ -193,8 +211,15 @@ requests as local:
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
up — it gates everything except the (secret‑gated) hook exemption and is the
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
without a login (`CODEMAN_PASSWORD` or multi‑user mode) unless
`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel`
acknowledges the exposure, or the request carries `acknowledgeUnauthTunnel: true`
(what the browser sends after its confirm). The network acknowledgement
(`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`) does **not** count: the installer writes
it for every LAN install without a password, so it once turned a click on the
welcome screen's tunnel link into a public, login‑free URL with no confirm. With
no login the browser asks before every enable, whatever the server would accept,
reading `loginConfigured` from `GET /api/tunnel/status`. Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all.
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
@@ -569,7 +594,8 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
| `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning). Does not cover the tunnel |
| `--allow-unauthenticated-tunnel` / `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Let the managed Cloudflare tunnel start without a login (the browser still asks first). Never written by the installer |
| `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
+1 -1
View File
@@ -192,7 +192,7 @@ start/stop wrapper to manage; tailscaled owns the lifecycle).
| First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. |
| `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. |
| Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. |
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedTunnelAcknowledged` and the login check in the `PUT /api/settings` tunnel guard). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
| Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. |
| User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). |
+4 -1
View File
@@ -193,7 +193,10 @@ wears a bar across the top:
That is the **session strip**: the other sessions stay visible from inside a pane,
numbered exactly as the dashboard numbers them, with the one you are in inverted.
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. With
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. Those keys
run `switch-client` only for this terminal's tmux client, so a browser tab on the
same socket cannot use them to jump sessions, Esc followed by a digit included (that
tab's pane still receives the Esc and the digit). With
more sessions than fit, the strip shows a window around the current one and marks
each cut end with `…`; the way-out hint is measured first and always keeps its space.
+26 -2
View File
@@ -30,8 +30,13 @@ A **MAJOR** bump is required to break any of these after 1.0:
3. **Documented environment variables** that configure deployment:
`CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`,
`CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`,
`CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags.
Removing or changing the meaning of one of these is breaking.
`CODEMAN_TMUX_SOCKET`, `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`,
`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`, and the `--host` / `--port` /
`--https` / `--allow-unauthenticated-network` /
`--allow-unauthenticated-tunnel` CLI flags. Removing or changing the meaning
of one of these is breaking. The two acknowledgements are listed from 1.46.0,
the release that split them: the network one covers the non-loopback bind
only, and an unauthenticated tunnel needs the tunnel one.
4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the
uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and
conventional HTTP status codes. Endpoint paths, the response envelope, error
@@ -62,6 +67,25 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
anything labeled experimental in the UI or docs. These may change or be
removed at any time.
### Experimental agent coordination
`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and
the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage`
and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since
every `/api` route is also served under `/api/v1`), are labeled experimental, so
item 3 above applies to them: their commands, flags, endpoint paths, payloads and
behavior may change or be removed in a MINOR or PATCH release. They are also
opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced
`agentCoordinationEnabled` setting (default OFF) until it is turned on, so
automation cannot come to depend on them without choosing to. Pin a compatible
Codeman version when automating them. Existing authentication, ownership and
shared response-envelope rules still apply. The session behavior underneath them
(input revision tracking in the session's write paths, the SubmitVerifier
re-press going through the shared write boundary) runs for every session and is
covered like any other stable behavior. Other agent commands and API routes keep
their existing guarantees. Promotion to stable requires an explicit documented
decision.
## Deprecation policy
When we need to change a covered surface:
+7 -2
View File
@@ -123,8 +123,11 @@ message a worker directly instead of typing into its terminal.
## What it will and will not do
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and
never guesses an API URL, so a global install costs an unrelated agent session nothing.
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses every live
API operation and never guesses an API URL, so a global install cannot drive a server it
is not part of. The one exception is reading saved Codex conversations with
`codeman history read` (see [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#reading-saved-codex-conversations)),
a local, read-only command that needs no server or session.
- **Unprompted, it may only** start sessions, prompt them, and delete the ones **it created
in that conversation, by exact id**, behind a guard that refuses to delete the agent's own
session.
@@ -153,6 +156,8 @@ The skill checks for both and says so instead of acting.
| [`reference/recipes.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/recipes.md) | Worked flows: claude, DeepSeek Harness and shell workers, fan-out, watching a blocked worker, messaging. Loaded on demand. |
| [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. |
| [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. |
| [`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. Loaded on demand. |
| [`reference/inspection-and-staging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in (App Settings, Experimental agent coordination): bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. Loaded on demand. |
Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a
quick run stays quick.
+70 -41
View File
@@ -14,23 +14,52 @@ Codeman-side configuration:
- Which CLI the Run button should default to.
- Per-case toggles (Agent Teams, 1M Opus context).
- A **Default session group** (Case settings…): new sessions of the case land in that tab group of the
vertical rail unless you start them from a group's own *New session* or they follow the session that
spawned them. *Ungrouped* turns it off. It follows you across devices, never moves existing tabs, and a
vertical rail unless you start them from a group's own _New session_ or they follow the session that
spawned them. _Ungrouped_ turns it off. It follows you across devices, never moves existing tabs, and a
deleted group simply falls back to Ungrouped.
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
registration, and for a linked case that is all it removes.
**Renaming a case.** In the case manager (Settings, or the case picker's manage view) the pencil
button renames a case and its folder. A case under `~/codeman-cases` _is_ its folder name, so it
always moves. A linked case renames its name in the list only, unless you tick **Also rename the
folder on disk** in the rename dialog: then its real folder is renamed too, next to where it is.
Remote and Docker cases cannot be renamed. Renaming does the following for you:
- **Sessions are never moved from under you.** If live sessions have the folder open, the rename is
refused and lists them; you can then choose to stop them. That closes them the way closing their tabs
does (nothing is restarted, and the last one to close removes the files attached to prompts in the
case's `.codeman-uploads`). While a rename runs, Codeman holds the folder so no session can start
inside it half-moved. A session that is just being started there stops the rename, which says so;
trying again a moment later lists that session with the others.
- **A folder another program holds open is reported, not forced.** On Windows, SMB shares and some FUSE
mounts a directory with an open handle cannot be renamed; Codeman retries briefly, then says the folder
is in use and to close whatever has it (a terminal, an editor, a file browser, a sync client).
- **Claude Code's history follows.** It keys conversations by the folder's path, so the rename moves
`<claude config>/projects/<old path>` to the new path and `--resume` still finds them. When the new
path already has history (a case deleted earlier under that name leaves its history behind), the
conversations are merged into it without overwriting anything. Other CLIs'
history, Docker mounts and `git worktree` links that point at the old path are not updated. The
Resume list uses the renamed folder too, and the case's default session group follows the new name.
- **Not migrated.** Scheduled (cron) jobs keep the old `workingDir` and fail at their next run until you
edit them; Claude history of sessions started in a subfolder (including `.claude/worktrees/<name>`)
stays under the old path. Claude Code's own per-project settings in `~/.claude.json` (local-scope MCP
servers, allowed tools, the folder's trust) are keyed by the old path too, so the renamed case starts
without them: Codeman does not write another CLI's configuration here. In multi-user mode only an
admin's rename updates the shared case order and last used case.
- **The case order, the last used case and this browser's per-case options follow the new name.**
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
@@ -75,10 +104,10 @@ Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one pla
Where a case runs is **separate from** which CLI it runs. There are three locations:
| Location | What happens |
| -------------- | ------------------------------------------------------------------------------------------------------------ |
| **Local** | The default. tmux and the CLI run on the Codeman host. |
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| Location | What happens |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Local** | The default. tmux and the CLI run on the Codeman host. |
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
This matters because it is a common source of confusion: Docker is **not** an eleventh run
@@ -113,14 +142,14 @@ sessions never appear in a bare `tmux ls`.
## What persists
| Survives | Does not survive |
| -------------------------------------------- | --------------------------------------------------- |
| Closing the browser | `tmux -L codeman kill-server` |
| Losing the network | A machine reboot (tmux dies with it) |
| Restarting the Codeman server | Killing the session from the UI |
| `codeman web --stop` | |
| A dropped SSH link, for remote cases | |
| A container restart, for docker cases | |
| Survives | Does not survive |
| ------------------------------------- | ------------------------------------ |
| Closing the browser | `tmux -L codeman kill-server` |
| Losing the network | A machine reboot (tmux dies with it) |
| Restarting the Codeman server | Killing the session from the UI |
| `codeman web --stop` | |
| A dropped SSH link, for remote cases | |
| A container restart, for docker cases | |
Conversation history is a separate question: Claude transcripts live in `~/.claude/`, so a
conversation can be resumed even after the tmux session is gone. That is what the welcome
@@ -130,18 +159,18 @@ screen's **Resume Conversation** list offers.
Everything Codeman knows lives under `~/.codeman/`:
| File | Holds |
| ---------------------------------------- | -------------------------------------------------------------------- |
| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. |
| `settings.json` | User preferences that sync across your devices. |
| `mux-sessions.json` | tmux recovery data. |
| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. |
| `webviews.json` | Saved dashboard URLs. |
| `users.json` | Multi-user accounts, mode 0600. |
| `push-*.json` | Web push keys and subscriptions. |
| `certs/` | Self-signed TLS for `--https`. |
| File | Holds |
| ---------------------------------------- | --------------------------------------------------------------------------------------------- |
| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. |
| `settings.json` | User preferences that sync across your devices. |
| `mux-sessions.json` | tmux recovery data. |
| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. |
| `webviews.json` | Saved dashboard URLs. |
| `users.json` | Multi-user accounts, mode 0600. |
| `push-*.json` | Web push keys and subscriptions. |
| `certs/` | Self-signed TLS for `--https`. |
| `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). |
None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets
@@ -184,17 +213,17 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Vocabulary
| Term | Means |
| --------------- | ---------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator**| A phased plan driven across multiple agents. |
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
| **Instance** | One Codeman server with its own data directory and tmux socket. |
| Term | Means |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator** | A phased plan driven across multiple agents. |
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
| **Instance** | One Codeman server with its own data directory and tmux socket. |
## Read next
+72 -1
View File
@@ -41,23 +41,94 @@ act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"oth
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default)
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane
codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new
codeman agent ack <msg-id> # remove what you handled (read never removes)
codeman agent rm "$SID" # any session except this one
```
- **`spawn [case]`** uses the current directory when no case is supplied. Use
`codeman agent spawn --working-dir /path/to/project --mode codex` for another existing
local directory. A case and `--working-dir` cannot be combined. An empty case name
(`spawn "$CASE"` with `CASE` unset) is never read as "no case": it goes to quick-start,
which refuses it (exit 1). An empty `--working-dir` is refused before any request
(exit 4), so it never falls back to cwd.
A cwd that is your home directory is refused the same way, because Codeman's hooks would
land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose.
Explicit case names retain quick-start behavior (creating a scratch case if missing).
A directory launch is `POST /api/v1/sessions` followed by
`POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and
parent lineage, and never labels it as agent scratch. Choose a separate worktree for
each worker that will edit code. If startup fails or its response is lost, the CLI
reports the exact created session ID and exits `1`; inspect that session before
deciding what to do next.
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
ambiguous prefix.
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
prompt is output too, so ask for the marker in halves and wait on the joined form.
- **`post`, `inbox`, `ack`** are the agent inbox: a message for another session that starts
no turn. `post` stores ONE quoted argument (multi-line is fine, so `"$(cat task.md)"` works)
and types nothing into the pane. `inbox` reads this session's mail, or peeks at another's
with an id, and removes nothing; `ack <id>…` removes what you handled. `inbox --wait`
blocks while nothing is new, so mail you read but did not ack does not wake it again.
`ls` shows each session's mail in an INBOX column. Mail lives in memory unless
**Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)),
and goes when its session is deleted.
- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN`
naming the setting until **Experimental agent coordination** is on (see
[Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by
default), and they may change in any release. `inspect` returns bounded evidence with an
opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a
local Codex composer, with no Enter, and returns a receipt that expires after two minutes
once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands
and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter
would accept), so send commands with `send`. `submit` sends one Enter only if the
composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details:
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input).
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
worker exited, `4` refused. `--json` prints the response's `data`.
worker exited, `4` refused. `--json` prints the response's `data`, except a directory
`spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a
readiness wait ran, `readinessError` when that call failed, and `startupError` when the
start failed.
## Reading saved Codex conversations
`codeman history read` is the one command here that needs no server and no Codeman
session: it reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else
`~/.codex/sessions`) from disk and writes nothing.
```bash
codeman history read --session-id 019fc604 # one conversation, by id or 8+ character prefix
codeman history read --root-session-id <uuid> --topic auth # it and every sub-agent or fork under it
codeman history read --file /path/to/rollout.jsonl --format text
```
- **Filters** keep whole user/assistant turns: `--topic` is a literal, case-insensitive
match, and `--after`/`--before` are inclusive UTC dates.
- **Forks and sub-agents**: a child's copy of its parent's history is dropped, so every
record belongs to the thread that wrote it.
- **`complete: false`** in the JSON means the evidence is partial: a store file that could
not be attributed, a compressed `.jsonl.zst` rollout (not read), a record a running
Codex is still writing, an ancestor not found.
- **Budgets** fail closed and are raisable: `--metadata-bytes` (attributing the store,
default 1 GiB), `--max-entries` (65536), `--scan-bytes` (the selected transcripts,
16 MiB) and `--max-bytes` (output, 8192). `--json` and `--format text` together are an
error.
The full reference ships with the skill as
[`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md).
## The MCP server
+1 -1
View File
@@ -42,7 +42,7 @@ codeman web # then open http://localhost:3000
**Driving it from code**
- [The Codeman Skill](Codeman-Skill) - one prompt, a whole crew: an agent starting and supervising worker sessions.
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives.
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives, reading saved Codex conversations offline.
- [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events.
- [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman.
+1 -1
View File
@@ -8,7 +8,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| Shortcut | Action |
| ------------------------------- | --------------------------------------------------------------- |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. On the **New session** row, `Left` / `Right` change the agent it starts. |
| `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
+9 -2
View File
@@ -37,8 +37,9 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s
the hinge, and opening or closing the device is treated as the device changing shape, never
as the keyboard appearing.
**Swipe left and right** on the terminal to switch sessions. A visible text selection pauses
the swipe, and a terminal wider than the screen pans sideways instead.
**Swipe left and right** on the terminal to switch sessions, in the order the tabs are shown
(a grouped or sorted tab layout included). A visible text selection pauses the swipe, and a
terminal wider than the screen pans sideways instead.
## The home screen
@@ -152,6 +153,12 @@ already writing in the editor.
- The terminal and toolbar shift up when the keyboard opens, tracked through the browser's
visual viewport rather than guessed.
- **Opening the keyboard does not resize a Claude session.** Each resize made Claude reprint
its screen at the new height and drop transcript lines from the session's history, so for a
local Claude session the terminal keeps its size while the keyboard is up and the view
scrolls instead, with the prompt and the line under it just above the keyboard. Codex,
Claude's fullscreen mode, remote and Docker sessions still resize. **Keep Size Under
Keyboard** in App Settings turns it off on this device.
- **Two ways to dismiss the keyboard**: tap outside the terminal on inert space, or tap twice
on inert terminal content. Tapping a control never dismisses it, and tapping the prompt row
keeps focus so you can place the caret.
+10 -2
View File
@@ -115,8 +115,10 @@ There is also a toggle in **App Settings → System → Remote access**.
**The tunnel refuses to start without a password.** That is on purpose: a public URL with no
authentication is a terminal on your machine handed to the internet. Acknowledging the risk
explicitly is possible from the UI toggle, and only from there; the API will not do it for
you.
explicitly is possible from the UI toggle, which asks you every time, or for the whole server
with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` (or `codeman web --allow-unauthenticated-tunnel`).
`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, which the installer sets for a LAN install without a
password, does not count.
## LAN plus password
@@ -239,6 +241,12 @@ minute decay.
A valid cookie or a correct password recovers immediately even while an attacker is hammering
the same IP, which matters because all tunnel traffic arrives from one loopback address.
Multi-user mode is different: there the per-IP cap is 50, each account allows ten failed
guesses from one IP, and both are checked before the password. Behind a tunnel every client
shares the loopback address, so past 50 failures in total a correct password waits for the
decay for everyone, and past ten failures for one account it waits for that account (the
account still logs in from another IP). A valid cookie keeps working throughout.
## Terminal alternatives
You do not have to use a browser. `codeman tui` is a full-screen session dashboard that
+3 -2
View File
@@ -20,7 +20,8 @@ In order of how much they matter:
1. **Do not expose it without `CODEMAN_PASSWORD`.** Binding a non-loopback host without one
starts, but warns loudly. A tunnel refuses outright unless you acknowledge the exposure
in the UI.
in the UI, which asks every time, or set `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`. The
installer's `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers the LAN bind only.
2. **Prefer Tailscale over a public tunnel.** Keeping the loopback bind and putting a
private network in front of it removes the public attack surface entirely, and gives you
real HTTPS. See [Remote Access](Remote-Access).
@@ -53,7 +54,7 @@ When authentication is enabled:
| ------------------- | ------------------------------------------------------------------------------------------------ |
| **HTTP Basic** | `CODEMAN_USERNAME` (default `admin`) and `CODEMAN_PASSWORD`. |
| **Session cookie** | A 256-bit opaque token validated server side, so it cannot be forged offline. 24 hours, extended on activity, with a device-context audit trail. |
| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. |
| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. In multi-user mode the per-IP cap is 50, and each account allows ten failed guesses from any one IP. Both are checked before the password, so past either a correct password from that IP also waits for the decay (the account still logs in from another IP, and a valid cookie keeps working). From the tenth failed guess on, an account's wrong guesses get a `429` from every IP, which never refuses a correct password. |
| **QR auth** | Single-use 60-second tokens with their own separate rate limiter, so a mistyped password cannot lock out QR login. |
| **Hook endpoints** | The hook and telemetry endpoints skip Basic auth because they are called from localhost by the CLI, but when auth is on, that bypass additionally requires a per-instance hook secret. |
+50 -4
View File
@@ -21,7 +21,7 @@ desktop.
| Category | Examples |
| ----------------------- | ------------------------------------------------------------------------------- |
| **Per-device, local** | Skin, WebGL renderer, local echo, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. |
| **Per-device, local** | Skin, WebGL renderer, local echo, keep size under keyboard, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. |
| **Per-device policy** | Most `show*` toggles, plan usage chip, language. Stored server-side, but a device only takes the server value when it has no local one of its own. |
| **Synced** | Models, effort, CLI options, notification preferences, voice settings, display name, the agent skill and approvals toggles. |
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Setting | Default | Notes |
| ----------------------------- | -------------------- | --------------------------------------------------------------------- |
| Local Echo | On for touch devices | Paints keystrokes locally and flushes on Enter. See [Input And Voice](Input-And-Voice). |
| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). |
| CJK Input | Off | IME composition through a dedicated text field. |
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
@@ -65,6 +66,16 @@ Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Life
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
**Host readings** (opt-in, both off by default and per device): **GPU Stats** adds three items after CPU and MEM: GPU load, graphics memory and the GPU clock,
and **CPU MHz** adds the live CPU clock. Both sets of items live inside **System Stats**, so they show only
while that chip is on, and nothing is fetched while it is off. An item appears only when the
host reports it, so a machine with no readable GPU shows nothing extra. Sources: `nvidia-smi` or the amdgpu
sysfs files for the GPU; inside WSL, the Windows performance counters through `typeperf.exe`, which cover
any vendor (an Intel GPU gives load and memory in use, but Windows publishes no clock for it, and its memory
total is taken as half of Windows' RAM, the limit Windows applies to integrated GPUs); and
`scaling_cur_freq`, `/proc/cpuinfo` or, inside WSL, the Windows `Processor Frequency` counter for the CPU
clock. Nothing is read, and `nvidia-smi` and `typeperf.exe` are never run, while both are off.
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
@@ -74,8 +85,26 @@ by folder** (per device, on by default) shows changed files under collapsed fold
window; off lists every file by its full path. **Git status: max repositories** (per device,
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
long one git command may run before that repository is reported as unreadable; raise it for
repositories on a slow network share.
long one git command may run before that repository is reported as unreadable, and the same value
bounds the Git window's Fetch button; raise it for repositories on a slow network share.
**Git fetch on an interval** (synced, off by default) runs `git fetch` on a timer for the repositories the
Git window lists, so the `behind` number is current without a click, and **Git fetch: how often** (synced,
30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own, timed from
the start of a run, so a slow repository does not push the next one out. These are the only Git settings
that are synced rather than per device, because the server's scheduler is what acts on them: kept per
device, the scheduler would read nobody's number. **Session Options → Git** overrides both the switch and
the interval for one session, and that override wins even while the app switch is off; a session that follows
the app shows the app's values and its rows go inert until you turn **Follow the app setting** off. Nothing is
ever fetched for a Docker or remote (SSH) session, and a repository a container can write to is refused
before any git runs. Sessions open on the same folder share one run, and a folder is fetched when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins. Both default to off because `git fetch`
is the one git command the Git window runs that writes (cloning and worktree management write too, but only
when you asked for them at the moment you asked), and the one that can run a repository's own hooks
(`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as
`credential.helper`), and the one that can run later by itself, on a timer. The ssh it runs is BatchMode so
nothing waits for a login, and that is a default, never an override: your own `GIT_SSH_COMMAND`, or the
repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new`
records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write.
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
@@ -89,6 +118,21 @@ way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
This section also holds background-agent tracking, including whether to track agents for
every session or only the active tab.
**Keep agent inbox across restarts** (under **Cross-session features**, synced, off by
default) saves the pending `codeman agent post` messages to `agent-inbox.json` in the data
dir, readable by you only, so they survive a server restart. Off, the agent inbox lives in
memory and a restart empties it; switching it off deletes the file and keeps what is in
memory. Messages expire 12 hours after they were posted either way. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
**Experimental agent coordination** (under **Cross-session features**, synced, off by
default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded,
paged reads of another session's output, and typing a prompt into a local Codex composer
that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP
routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are
experimental and may change in any release. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
### Appearance
| Setting | Notes |
@@ -104,6 +148,7 @@ every session or only the active tab.
| Tall Tabs | Taller tab strip. |
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| New Session Button on Tabs | Adds a **+** beside each tab's gear: pick a CLI the tab's case can start and one session starts in that case (or worktree) and named group, leaving the Run button alone. The row menu offers it as *New session here…*. Per device, off by default. See [The Dashboard](The-Dashboard#session-tabs). |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. |
@@ -227,7 +272,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_GESTURE` | Makes gesture control available to be enabled. |
| `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. |
| `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. |
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges binding a non-loopback address with no password. Does not cover the tunnel. |
| `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Lets the Cloudflare tunnel start with no password (`--allow-unauthenticated-tunnel` on `codeman web`). The browser still asks first. |
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
+16
View File
@@ -77,6 +77,14 @@ An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
headers and the Run menus keep theirs.
**New Session Button on Tabs** (same place, per device, off by default) adds a **+** beside
the tab's gear. It lists the CLIs that tab's case can start, each with its logo and name (a
Docker case lists what its container has), and a pick starts one session in that tab's case,
or the worktree it runs in, and in its named group. The Run button keeps its own mode and case.
Wherever a tab has a row menu (the vertical rail, the sidebar, `Shift+F10` on a grouped row),
the same list is its **New session here…** entry. From a tab outside any named group, the new
session lands in the case's default group if the case has one, as a Run from the toolbar does.
**Status is carried by the dot and the tab's own styling:**
| Look | Meaning |
@@ -178,6 +186,14 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w
Hiding System Stats or Plan Usage still hides them in every style.
The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar
(it wraps onto a second line and the usage readings shed their labels, then their provider names,
then, in the Compact style, their percentages), left to widen it. The GPU, VRAM and clock readings
keep their labels, since their values look like CPU's and MEM's; at the narrowest Compact step a
clock, which has no ring, is hidden. Arrow keys resize it, and
double-click or Home resets it. It stops at the widest reading, the width is kept per device, and it
is not shown on phones, in the sidebar layout, in the vertical rail or in a solo window.
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
+24 -5
View File
@@ -245,12 +245,31 @@ Click it for a draggable window, in the style of the File Viewer:
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
in your home folder) is ignored.
- **Fetch** in the header runs `git fetch` for the repositories the window lists, on your click; the
window then re-reads, so "behind" is as of that fetch. A repository that could not be fetched keeps
its row and shows why. **Git fetch on an interval** (Settings → Bottom bar, off by default) runs the
same fetch on a timer for every session that is on, and **Session Options → Git** overrides both the
switch and the interval for one session: a session that turned itself on runs even while the app-wide
switch is off, sessions open on the same folder share one run, and nothing is ever fetched for Docker or
remote (SSH) sessions.
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
workspace is skipped even from a local session (a container can write there, and git would run
that repository's own configuration on the host). The
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
It is read-only apart from that fetch: Codeman never commits, checks out or changes the
repository, and `git fetch` writes remote-tracking refs only; it is the only git command the Git window
runs that writes, the only one that can run a repository's own hooks (`reference-transaction`,
`pre-auto-gc` through the maintenance a fetch triggers, and config commands such as `credential.helper`),
and the only one that can run later by itself, on a timer: cloning and worktree management write too, but
only when you asked for them at the moment you asked, which is why nothing runs on a timer until you opt in. A fetch holds git's ref lock, so an agent's own `git pull` in the same repository
can hit the same `cannot lock ref`, and a folder is fetched by one run at a time. It is not shown for Docker
or remote (SSH) sessions, and a repository at or inside a Docker case
workspace is skipped even from a local session, before any git runs (a container can write there,
and git would run that repository's own configuration on the host). Fetch asks nothing of you: it
uses the sign-in your own git already has, and it gives up quickly rather than waiting on an
invisible login prompt. That BatchMode ssh is a default, never an override: your own
`GIT_SSH_COMMAND`, or the repository's `core.sshCommand`, is used when either is set, and ⚠️ the default's
`StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking:
BatchMode stops a prompt, not a write. The
data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-diff` and, on that
click or the interval you turned on, `POST /api/sessions/:id/git-fetch`
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
## Gotchas
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.44.0",
"version": "1.46.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.44.0",
"version": "1.46.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -13389,7 +13389,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.4.1",
"version": "0.4.2",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.44.0",
"version": "1.46.0",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
+38
View File
@@ -1,5 +1,43 @@
# xterm-zerolag-input
## 0.4.2
### Patch Changes
- de1aab9: ### Thanks
- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710).
- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687).
- @opticon454 for the Fetch button and fetch interval in the Git status window (#714).
- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717).
**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency.
**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard.
**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before.
**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too.
**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link.
**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request.
**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it.
**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written.
**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual.
**Fixes.**
- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy.
- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session.
- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message.
- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865.
**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707).
**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill.
## 0.4.1
### Patch Changes
+22 -12
View File
@@ -8,8 +8,8 @@
<a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
<img src="https://img.shields.io/badge/Size-5.7%20kB%20gzip-22c55e?style=flat-square" alt="5.7 kB minified and gzipped">
<img src="https://img.shields.io/badge/Tests-276-22c55e?style=flat-square" alt="276 tests">
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
</p>
</p>
@@ -60,12 +60,13 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
| | |
|---|---|
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
| **Pixel-matched to the canvas** | Each character is its own `<span>`, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. |
| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. |
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. |
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
@@ -128,7 +129,7 @@ This library answers all three:
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
- **one `<span>` per character**, sized to its cells and snapped to the cell grid
---
@@ -205,6 +206,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. |
### Backspace handling
@@ -508,15 +510,23 @@ div.xterm-screen (position: relative)
### Per-character grid alignment
Each character is an absolutely-positioned `<span>`:
Each line is an absolutely-positioned `<div>` at its row, and each character is a `<span>` in a flex row inside it, as wide as the cells it covers:
```
left = visualColumn * cellWidth (CSS pixels)
top = lineIndex * cellHeight (CSS pixels)
width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
line top = lineIndex * cellHeight (CSS pixels)
edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel)
span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters)
```
Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`.
The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited.
### Rendering cost
Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element.
Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames).
### Font matching
@@ -543,7 +553,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re
## Known limitations
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px.
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨‍👩‍👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.4.1",
"version": "0.4.2",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
@@ -46,14 +46,61 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
// ─── Overlay rendering ────────────────────────────────────────────────
/**
* Cell edges are snapped to this fraction of a CSS pixel.
*
* The glyph spans sit in normal flow, so each one starts where the widths
* before it add up to, and a width the layout engine cannot store exactly
* would carry its rounding error into every later cell (the drift that plain
* DOM text shows against the canvas grid). A quarter pixel is exact in
* Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units),
* so the widths always add up to the snapped edge: no drift at any column,
* and every edge is within 1/8 px of `col * cellW`.
*/
const EDGE_SNAP = 4;
function cellEdge(col: number, cellW: number): number {
return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP;
}
/** What the last render left in a container, so the next one rebuilds only the lines that changed. */
interface RenderedLines {
/** Everything a line's markup depends on besides its own text and position. */
styleKey: string;
lines: HTMLDivElement[];
lineKeys: string[];
cursor: HTMLSpanElement | null;
}
const rendered = new WeakMap<HTMLDivElement, RenderedLines>();
/**
* False once anything else has emptied or edited the container (the addon's
* hide path clears it): the children must be exactly the recorded lines, in
* order, then the cursor.
*/
function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean {
const children = container.children;
if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false;
for (let i = 0; i < state.lines.length; i++) {
if (children[i] !== state.lines[i]) return false;
}
return !state.cursor || children[state.lines.length] === state.cursor;
}
/**
* Render the overlay content into the container element.
*
* Creates per-character `<span>` elements positioned on an exact grid
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
* occurs with normal DOM text flow.
* Each line is a `<div>` holding one `<span>` per character on the cell grid
* of xterm.js's canvas renderer (see makeLine). CJK wide characters get
* double-width spans.
*
* CJK wide characters are rendered with double-width spans.
* Only lines whose content or position changed since the previous render
* into the same container are rebuilt. Typing a character changes just the
* last line, so a keystroke costs one line of DOM work however long the
* prompt is; rebuilding everything made each keystroke O(prompt length) in
* DOM work, layout and paint, which a long prompt on a phone paid for in
* dropped frames.
*/
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const {
@@ -94,11 +141,14 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// Code-point offset of each line in the whole text, so the composition
// styling survives the tail slice below.
const lineOffsets: number[] = [];
const lineLengths: number[] = [];
{
let offset = 0;
for (const line of lines) {
const length = [...line].length;
lineOffsets.push(offset);
offset += [...line].length;
lineLengths.push(length);
offset += length;
}
}
let visibleLines = lines;
@@ -120,8 +170,29 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
container.style.left = '0px';
container.style.top = topRow * cellH + 'px';
// Clear and rebuild (typically 1-3 line divs, negligible cost)
container.innerHTML = '';
// Reuse the previous render's lines when nothing they depend on but their
// own text and position changed; otherwise start from an empty container.
const styleKey = [
cellW,
cellH,
font.fontFamily,
font.fontSize,
font.fontWeight,
font.color,
font.backgroundColor,
font.letterSpacing,
terminal?.unicode?.activeVersion ?? '',
].join('\n');
let state = rendered.get(container);
if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) {
container.innerHTML = '';
state = { styleKey, lines: [], lineKeys: [], cursor: null };
rendered.set(container, state);
}
// Taken out while the lines change, so it is appended last again below.
state.cursor?.remove();
state.cursor = null;
const fullWidthPx = totalCols * cellW;
for (let i = 0; i < visibleLines.length; i++) {
@@ -129,8 +200,16 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
const lineCompositionFrom =
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
// Clamped to the line, so a line the composition does not reach keeps
// the same key (and its element) while the composition moves along.
let lineCompositionFrom: number | undefined;
if (compositionStart !== undefined) {
const from = compositionStart - lineOffsets[firstVisible + i];
if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from);
}
// Text goes last, so no character in it can make two different lines collide.
const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`;
if (state.lineKeys[i] === key) continue;
const lineEl = makeLine(
visibleLines[i],
leftPx,
@@ -144,8 +223,15 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
terminal,
lineCompositionFrom
);
container.appendChild(lineEl);
const previous = state.lines[i];
if (previous) container.replaceChild(lineEl, previous);
else container.appendChild(lineEl);
state.lines[i] = lineEl;
state.lineKeys[i] = key;
}
for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove();
state.lines.length = visibleLines.length;
state.lineKeys.length = visibleLines.length;
// Block cursor at end of last line (use visual width for CJK support)
if (showCursor) {
@@ -161,6 +247,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
state.cursor = cursor;
}
}
@@ -168,13 +255,20 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
}
/**
* Create a styled line `<div>` with per-character grid positioning.
* Create a styled line `<div>` with one `<span>` per character on the cell grid.
*
* Each character gets its own `<span>` positioned by visual column offset.
* CJK wide characters occupy 2 cell widths. Characters at or after
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
* composition text: underlined, like xterm's own composition view, and marked
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
* The spans sit side by side in a flex row, each exactly as wide as its cells
* (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the
* widths add up to the grid without drifting. They are deliberately not
* absolutely positioned: a positioned element gets its own paint layer, and
* with one per character a long prompt left the browser re-layerizing and
* repainting thousands of them on every keystroke. Everything the spans share
* (font, colour, line height, centring, ligatures off) is set once on the
* line and inherited.
*
* Characters at or after `compositionFrom` (a code-point index into `text`)
* are IME composition text: underlined, like xterm's own composition view, and
* marked `data-zerolag-composition` + `aria-hidden` since they are provisional.
*/
function makeLine(
text: string,
@@ -190,7 +284,9 @@ function makeLine(
compositionFrom?: number
): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
// No ligatures: the canvas renders each glyph independently.
el.style.cssText =
"position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0";
el.style.backgroundColor = font.backgroundColor;
el.style.left = leftPx + 'px';
el.style.top = topPx + 'px';
@@ -200,27 +296,24 @@ function makeLine(
// The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured.
el.style.height = cellH + 1 + 'px';
el.style.lineHeight = cellH + 'px';
el.style.fontFamily = font.fontFamily;
el.style.fontSize = font.fontSize;
el.style.fontWeight = font.fontWeight;
el.style.color = font.color;
if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing;
// CJK wide chars occupy 2 cells — position by visual column offset
let colOffset = 0;
// Each span runs from the previous snapped edge to its own, so the spans
// land on the grid by visual column (CJK wide chars span 2 cells).
const spanHeight = ';height:' + cellH + 'px';
let col = 0;
let edge = 0;
let index = 0;
for (const ch of text) {
const cw = charCellWidth(terminal, ch);
col += charCellWidth(terminal, ch);
const nextEdge = cellEdge(col, cellW);
const span = document.createElement('span');
// No ligatures — canvas renders each glyph independently.
span.style.cssText =
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
"font-feature-settings:'liga' 0,'calt' 0";
span.style.left = colOffset * cellW + 'px';
span.style.top = '0px';
span.style.width = cw * cellW + 'px';
span.style.height = cellH + 'px';
span.style.lineHeight = cellH + 'px';
span.style.fontFamily = font.fontFamily;
span.style.fontSize = font.fontSize;
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight;
if (compositionFrom !== undefined && index >= compositionFrom) {
span.style.textDecoration = 'underline';
span.setAttribute('data-zerolag-composition', '');
@@ -228,7 +321,7 @@ function makeLine(
}
span.textContent = ch;
el.appendChild(span);
colOffset += cw;
edge = nextEdge;
index++;
}
@@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon {
private _bufferDetectDone = false;
// IME text still being composed: drawn after the pending text, never sent.
private _composition = '';
// Rows from the top of the screen that are actually visible (see setRowLimit);
// null means all of them.
private _rowLimit: number | null = null;
// Render cache
private _lastRenderKey = '';
@@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
/**
* Bound the layout to the first `limit` rows of the screen, or to all of them
* with `null`. It narrows the bound the overlay already keeps itself on
* screen with (`totalRows`, see _layoutRows), never adds a second one.
*
* For a host that keeps the terminal taller than the box showing it and
* scrolls the screen element instead (e.g. holding the PTY's rows while an
* on-screen keyboard covers the bottom): the composer must grow upward from
* the last row the user can SEE, not from the terminal's last row, which is
* under the keyboard.
*/
setRowLimit(limit: number | null): void {
const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null;
if (next === this._rowLimit) return;
this._rowLimit = next;
this.rerender();
}
/**
* The `totalRows` bound handed to renderOverlay: the one place that decides
* where the block stops growing downward. The terminal's rows, narrowed by
* setRowLimit when the host shows fewer; a limit above the terminal's rows
* changes nothing.
*/
private _layoutRows(): number {
const rows = this._terminal!.rows;
return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows;
}
/**
* Re-read font properties from the terminal and re-render.
* Call after font size changes, theme changes, etc.
@@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon {
// Skip redundant re-renders — include text content to detect
// same-length changes (e.g., setFlushed with different text)
// `rows` is part of the key: the layout is clamped to the visible rows
// (see renderOverlay), so a keyboard opening — which changes rows without
// changing the text — must not be skipped as a redundant render.
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
// (see renderOverlay), so a keyboard opening, which changes rows or the
// row limit without changing the text, must not be skipped as a
// redundant render.
const totalRows = this._layoutRows();
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
@@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon {
charTop,
charHeight,
promptRow: activePrompt.row,
totalRows: this._terminal.rows,
totalRows,
font: this._font,
showCursor: this._options.showCursor,
cursorColor,
@@ -14,7 +14,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
import { createMockTerminal } from './helpers.js';
import { createMockTerminal, lineSpans } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// ─── Shared fixtures ─────────────────────────────────────────────────
@@ -47,16 +47,7 @@ function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
/** Extract span data from a rendered line div */
function getSpans(lineDiv: HTMLDivElement) {
const spans: { text: string; left: number; width: number }[] = [];
for (let i = 0; i < lineDiv.children.length; i++) {
const span = lineDiv.children[i] as HTMLSpanElement;
spans.push({
text: span.textContent || '',
left: parseFloat(span.style.left),
width: parseFloat(span.style.width),
});
}
return spans;
return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width }));
}
// ─── Addon setup helpers ─────────────────────────────────────────────
@@ -199,18 +190,27 @@ describe('Test 2: CJK double-width spacing', () => {
it('CJK span widths are exactly 2 * cellW pixels', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('16.8px'); // 2 * 8.4
expect(span.style.width).toBe('17px'); // 2 * 8.5
});
it('ASCII span widths remain 1 * cellW pixels', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('8.4px'); // 1 * 8.4
expect(span.style.width).toBe('8.5px'); // 1 * 8.5
});
it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 }));
const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement);
expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125);
expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125);
expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125);
});
it('terminal unicode addon is preferred over fallback when available', () => {
@@ -644,8 +644,9 @@ describe('Test 6: ASCII input — no regression', () => {
const spans = getSpans(lineDiv);
expect(spans[0].left).toBe(0);
expect(spans[1].left).toBeCloseTo(8.4, 5);
expect(spans[2].left).toBeCloseTo(16.8, 5);
// Edges are snapped to a quarter pixel, so within 1/8 px of the grid.
expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125);
expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125);
});
it('ASCII cursor positions at correct column', () => {
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { createMockTerminal, lineSpans } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// setComposition(): IME text still being composed, drawn as an underlined tail
@@ -68,8 +68,9 @@ describe('setComposition', () => {
expect(span.style.textDecoration).toBe('underline');
}
// Grid positions continue straight on from the pending text.
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
const cells = lineSpans(line);
expect(cells[3].left).toBe(3 * CELL_W);
expect(cells[4].left).toBe(4 * CELL_W);
expect(overlay.style.display).toBe('');
});
@@ -79,9 +80,10 @@ describe('setComposition', () => {
addon.setComposition('天気');
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
const cells = lineSpans(lineDivs(overlay)[0]);
expect(cells[3].left).toBe(6 * CELL_W);
expect(cells[3].width).toBe(2 * CELL_W);
expect(cells[4].left).toBe(8 * CELL_W);
});
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
@@ -115,7 +117,8 @@ describe('setComposition', () => {
expect(compositionText(overlay)).toBe('WXYZ');
const second = Array.from(lines[1].children) as HTMLSpanElement[];
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
expect(second[0].style.left).toBe('0px');
expect(lineSpans(lines[1])[0].left).toBe(0);
expect(lines[1].style.left).toBe('0px');
});
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
@@ -204,3 +204,21 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
},
};
}
/**
* The glyph spans of a rendered overlay line, with each one's left edge and
* width in px. The spans sit side by side in a flex row, so a span's left
* edge is the sum of the widths before it (what the browser lays out), not a
* style property of its own; jsdom does no layout, so this adds them up.
*/
export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] {
const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = [];
let left = 0;
for (const child of Array.from(lineDiv.children)) {
const el = child as HTMLSpanElement;
const width = parseFloat(el.style.width);
spans.push({ text: el.textContent || '', left, width, el });
left += width;
}
return spans;
}
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
import { lineSpans } from './helpers.js';
const FONT: FontStyle = {
fontFamily: 'monospace',
@@ -38,7 +39,7 @@ describe('renderOverlay', () => {
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 }));
// Line div + cursor span
expect(container.children.length).toBe(2);
@@ -46,17 +47,43 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
const [a, b, c] = lineSpans(lineDiv);
expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]);
expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]);
expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]);
});
const spanB = lineDiv.children[1] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('8.4px');
it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'] }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.position).toBe('absolute');
expect(lineDiv.style.display).toBe('flex');
for (const { el } of lineSpans(lineDiv)) {
expect(el.style.position).toBe('');
expect(el.style.left).toBe('');
// flex: none, so a span is never stretched or squeezed off its cells.
expect(el.style.flexGrow).toBe('0');
expect(el.style.flexShrink).toBe('0');
}
});
const spanC = lineDiv.children[2] as HTMLSpanElement;
expect(spanC.textContent).toBe('c');
expect(spanC.style.left).toBe('16.8px');
it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => {
const container = document.createElement('div');
const cellW = 8.4;
renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW }));
const spans = lineSpans(container.children[0]);
let col = 0;
for (const span of spans) {
// Every width is a whole number of quarter pixels, which Blink, WebKit
// and Gecko all store exactly, so the browser adds them up exactly too.
expect(Number.isInteger(span.width * 4)).toBe(true);
expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125);
col += span.text === '你' ? 2 : 1;
}
// The far edge is still on the grid after 80 columns.
const last = spans[spans.length - 1];
expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125);
});
it('sets span width to cellW', () => {
@@ -67,7 +94,7 @@ describe('renderOverlay', () => {
expect(span.style.width).toBe('9.5px');
});
it('applies font styles to spans', () => {
it('applies font styles once on the line, for its spans to inherit', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
@@ -83,12 +110,16 @@ describe('renderOverlay', () => {
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
expect(lineDiv.style.fontFamily).toBe('Fira Code');
expect(lineDiv.style.fontSize).toBe('16px');
expect(lineDiv.style.fontWeight).toBe('bold');
expect(lineDiv.style.color).toBe('rgb(255, 0, 0)');
expect(lineDiv.style.letterSpacing).toBe('0.5px');
// Inherited, so a span sets none of them itself.
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.fontFamily).toBe('Fira Code');
expect(span.style.fontSize).toBe('16px');
expect(span.style.fontWeight).toBe('bold');
expect(span.style.color).toBe('rgb(255, 0, 0)');
expect(span.style.letterSpacing).toBe('0.5px');
expect(span.style.fontFamily).toBe('');
expect(span.style.color).toBe('');
});
it('offsets first line by startCol', () => {
@@ -217,47 +248,49 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
expect(span.style.lineHeight).toBe('19px');
// Set on the line and inherited by the span.
expect(lineDiv.style.lineHeight).toBe('19px');
});
it('span top is 0px (no vertical offset / no transform)', () => {
it('span has no vertical offset and no transform', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.top).toBe('0px');
// A flex item starts at the top of its line; nothing shifts it down.
expect(span.style.top).toBe('');
expect(span.style.marginTop).toBe('');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
// ─── Font rendering tests ─────────────────────────────────────
it('span disables ligatures via font-feature-settings', () => {
// These are inherited properties, set once on the line for every span.
it('line disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
expect(span.style.cssText).toContain('font-feature-settings:');
expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0");
expect(lineDiv.style.cssText).toContain('font-feature-settings:');
expect(lineDiv.style.cssText).toContain("'liga' 0");
expect(lineDiv.style.cssText).toContain("'calt' 0");
});
it('span has text-align: center for glyph centering', () => {
it('line has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.textAlign).toBe('center');
expect(lineDiv.style.textAlign).toBe('center');
});
it('span has pointer-events: none', () => {
it('line has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.pointerEvents).toBe('none');
expect(lineDiv.style.pointerEvents).toBe('none');
});
// ─── Multi-line cursor positioning ────────────────────────────
@@ -323,20 +356,18 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3);
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
expect(spanA.style.width).toBe('10px'); // 1 cell
const [spanA, spanCJK, spanB] = lineSpans(lineDiv);
expect(spanA.text).toBe('a');
expect(spanA.left).toBe(0);
expect(spanA.width).toBe(10); // 1 cell
const spanCJK = lineDiv.children[1] as HTMLSpanElement;
expect(spanCJK.textContent).toBe('你');
expect(spanCJK.style.left).toBe('10px'); // col 1
expect(spanCJK.style.width).toBe('20px'); // 2 cells
expect(spanCJK.text).toBe('你');
expect(spanCJK.left).toBe(10); // col 1
expect(spanCJK.width).toBe(20); // 2 cells
const spanB = lineDiv.children[2] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('30px'); // col 3
expect(spanB.style.width).toBe('10px'); // 1 cell
expect(spanB.text).toBe('b');
expect(spanB.left).toBe(30); // col 3
expect(spanB.width).toBe(10); // 1 cell
});
it('cursor position accounts for CJK width', () => {
@@ -360,13 +391,11 @@ describe('renderOverlay', () => {
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
const lineDiv = container.children[0] as HTMLDivElement;
// h(col 0), i(col 1), 你(col 2, width 2)
const spanH = lineDiv.children[0] as HTMLSpanElement;
expect(spanH.style.left).toBe('0px');
const spanI = lineDiv.children[1] as HTMLSpanElement;
expect(spanI.style.left).toBe('8px');
const spanCJK = lineDiv.children[2] as HTMLSpanElement;
expect(spanCJK.style.left).toBe('16px');
expect(spanCJK.style.width).toBe('16px');
const [spanH, spanI, spanCJK] = lineSpans(lineDiv);
expect(spanH.left).toBe(0);
expect(spanI.left).toBe(8);
expect(spanCJK.left).toBe(16);
expect(spanCJK.width).toBe(16);
});
});
@@ -503,3 +532,147 @@ describe('renderOverlay — staying on screen (totalRows)', () => {
expect(container.style.top).toBe(6 * 17 + 'px');
});
});
describe('renderOverlay: rebuilding only the lines that changed', () => {
const lineEls = (container: HTMLDivElement) =>
Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join('');
it('appending to the last line keeps every earlier line element', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] }));
const [first, second, third] = lineEls(container);
renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] }));
const after = lineEls(container);
expect(after[0]).toBe(first);
expect(after[1]).toBe(second);
expect(after[2]).not.toBe(third);
expect(after.map(text)).toEqual(['first', 'second', 'thi']);
});
it('keeps the cursor as the last child and moves it with the text', () => {
const container = document.createElement('div');
const params = { startCol: 0, cellW: 10 };
renderOverlay(container, makeParams({ ...params, lines: ['ab'] }));
renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] }));
const children = Array.from(container.children) as HTMLElement[];
expect(children.length).toBe(3);
const cursor = children[2];
expect(cursor.tagName).toBe('SPAN');
expect(cursor.style.left).toBe('10px');
expect(cursor.style.top).toBe(17 + 'px');
expect(container.querySelectorAll(':scope > span').length).toBe(1);
});
it('a wrapped line coming back (backspace) removes the extra line', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc', 'd'] }));
const [first] = lineEls(container);
renderOverlay(container, makeParams({ lines: ['abc'] }));
expect(lineEls(container)).toEqual([first]);
expect(container.children.length).toBe(2); // line + cursor
});
it('removes the cursor when the next render does not show one', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'] }));
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
expect(container.children.length).toBe(1);
expect(container.children[0].tagName).toBe('DIV');
});
it('a font, colour or cell size change rebuilds every line', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
const before = lineEls(container);
renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } }));
const recoloured = lineEls(container);
expect(recoloured[0]).not.toBe(before[0]);
expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)');
renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 }));
const resized = lineEls(container);
expect(resized[1]).not.toBe(recoloured[1]);
expect(lineSpans(resized[1])[1].left).toBe(12);
});
it('a Unicode version switch rebuilds lines whose text did not change', () => {
const container = document.createElement('div');
const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) };
const terminal = { unicode } as any;
renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
const before = lineEls(container)[0];
unicode.activeVersion = '11';
unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1);
renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
const after = lineEls(container)[0];
expect(after).not.toBe(before);
expect(lineSpans(after)[1].width).toBe(20);
});
it('starts over when something else emptied the container', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
container.innerHTML = '';
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
expect(lineEls(container).map(text)).toEqual(['one', 'two']);
expect(container.children.length).toBe(3);
});
it('starts over when something else reordered the lines', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
const [one, two] = lineEls(container);
container.insertBefore(two, one);
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
expect(lineEls(container).map(text)).toEqual(['one', 'two']);
expect(container.children.length).toBe(3);
});
it('starts over when something else added to the container', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['one'] }));
container.appendChild(document.createElement('div'));
renderOverlay(container, makeParams({ lines: ['one'] }));
expect(container.children.length).toBe(2);
expect(text(lineEls(container)[0])).toBe('one');
});
it('a composition growing on the last line leaves the lines it does not reach alone', () => {
const container = document.createElement('div');
// 'first' (5) + 'abc' pending, composition starts at code point 8.
renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 }));
const [first] = lineEls(container);
renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 }));
const after = lineEls(container);
expect(after[0]).toBe(first);
const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition'));
expect(marked.map((s) => s.textContent).join('')).toBe('xy');
});
it('a composition that starts on an earlier line marks the whole later line', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 }));
const [first, second] = lineEls(container);
const marked = (line: HTMLDivElement) =>
Array.from(line.children)
.filter((s) => s.hasAttribute('data-zerolag-composition'))
.map((s) => s.textContent)
.join('');
expect(marked(first)).toBe('XY');
expect(marked(second)).toBe('ZW');
});
it('two containers keep separate records', () => {
const a = document.createElement('div');
const b = document.createElement('div');
renderOverlay(a, makeParams({ lines: ['same'] }));
renderOverlay(b, makeParams({ lines: ['same'] }));
expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]);
expect(text(lineEls(a)[0])).toBe('same');
expect(text(lineEls(b)[0])).toBe('same');
});
});
@@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => {
expect(overlay.style.display).toBe('none');
});
});
describe('setRowLimit', () => {
function rowLimitSetup(promptRow = 10) {
const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : ''));
const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 });
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, overlay };
}
it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10
expect(overlay().style.top).toBe(`${10 * 17}px`);
addon.setRowLimit(12);
expect(overlay().style.top).toBe(`${9 * 17}px`);
});
it('null restores the full terminal height, and junk reads as null', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50));
addon.setRowLimit(12);
addon.setRowLimit(null);
expect(overlay().style.top).toBe(`${10 * 17}px`);
addon.setRowLimit(12);
addon.setRowLimit(Number.NaN);
expect(overlay().style.top).toBe(`${10 * 17}px`);
});
// setRowLimit narrows the SAME bound renderOverlay already keeps the block on
// screen with (RenderParams.totalRows, filled from terminal.rows), so there is
// one place that decides where the block stops growing downward.
it('narrows the terminal-rows bound and never widens it', () => {
const { addon, overlay } = rowLimitSetup(22);
addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21
expect(overlay().style.top).toBe(`${21 * 17}px`);
addon.setRowLimit(24);
expect(overlay().style.top).toBe(`${21 * 17}px`);
addon.setRowLimit(100);
expect(overlay().style.top).toBe(`${21 * 17}px`);
addon.setRowLimit(23);
expect(overlay().style.top).toBe(`${20 * 17}px`);
});
it('leaves a block that fits above the limit where it is', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50));
addon.setRowLimit(13); // rows 10..12 are exactly the three lines
expect(overlay().style.top).toBe(`${10 * 17}px`);
});
it('keeps the TAIL of a block taller than the limit', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters
addon.setRowLimit(2);
expect(overlay().style.top).toBe('0px');
const xs = [...overlay().querySelectorAll(':scope > div')].map(
(line) => (line.textContent ?? '').replace(/[^x]/g, '').length
);
expect(xs).toEqual([20, 12]);
});
it('holds across later keystrokes, not only the re-render it triggers', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50));
addon.setRowLimit(12);
addon.appendText('y');
expect(overlay().style.top).toBe(`${9 * 17}px`);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.44.0",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.46.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1 -1
View File
@@ -7,7 +7,7 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c
/plugin install codeman@codeman
```
The skill acts only inside a Codeman-managed session (`CODEMAN_MUX=1`) and refuses everywhere else, so installing it globally costs nothing for unrelated sessions.
The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions.
Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise.
+30 -8
View File
@@ -8,12 +8,19 @@ description: >-
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
another session, or start and manage workers. Only usable inside a Codeman-managed
session (CODEMAN_MUX=1); refuse to act otherwise.
another session, start and manage workers, or retrieve saved local Codex history
by file/native ID or root and descendants with topic/date filters. Live API
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
reads do not require a server or managed session.
---
# Driving Codeman from inside a session
For **saved Codex conversation history**, use `codeman history read` and
[reference/history.md](reference/history.md). This is a read-only local command:
route to it **before §0**, including outside a managed session. It needs no API
URL, credentials or running server. All live API operations below retain §0.
You are an agent running inside a Codeman-managed terminal session. Codeman is the
server that spawned you; its HTTP API can start, prompt, watch, and delete other
sessions.
@@ -30,20 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver
worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Experimental bounded inspection and guarded stage/submit load only when needed from
[reference/inspection-and-staging.md](reference/inspection-and-staging.md).
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`,
`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
no guessed URL) enforced in code, and it is also your own way through when this
machine has no `jq` for the preamble below.
For an existing project, use one command:
`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and
no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A
positional case keeps quick-start behavior and cannot be combined with
`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4
rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would
land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive.
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
@@ -119,9 +139,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -602,6 +622,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
| leave a message a session reads when it is ready, any mode, multi-line | `codeman agent post <id> "…"` (`POST .../inbox`). Nothing is typed into its pane and no turn starts; the receiver reads with `codeman agent inbox --wait` and removes with `codeman agent ack <id>`. An idle receiver never looks on its own: `codeman agent ls` shows unread mail, then `send` it one line | [§5.15](reference/verbs.md#515-agent-inbox) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -705,6 +726,7 @@ these**; open the one row you actually hit.
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
## 6. Setup and auth
+3 -3
View File
@@ -18,9 +18,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -35,8 +35,13 @@ only and can never drive a session.
with a parse error and `.errorCode` is simply absent (see
[symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one
IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a
failing credential in a loop**: you will lock the address out of the login path for
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user
mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and each account allows 10 failed
guesses per IP; both are checked before the password, so past either even a correct
password from that address gets the 429 (for the per-account one, only for that
account). From the 10th failed guess on, an account's wrong guesses also get the 429
from every address, but a correct password still logs in. **Never retry a failing
credential in a loop**: you will lock the address out of the login path for
everything, including the user's browser through a tunnel (tunneled traffic arrives as
127.0.0.1, so one bucket covers it all).
@@ -608,6 +613,22 @@ Approving a permission dialog on your own is exactly the laundering this skill f
can access, which includes the user's own working sessions. An approval belonging to one
of those is something you **report**, never something you answer.
### Agent inbox: hand a worker text without typing into its pane
Stored, not typed: the receiver reads it when it asks. Semantics, CLI form and the
receiver loop: `verbs.md` §5.15.
| Task | Call |
|------|------|
| post to a session's inbox | `POST /api/v1/sessions/:id/inbox` body `{"text":"…"}` → `.data.message.{id,from}`; 409 = inbox full, nothing stored |
| read (yours marks seen, anyone else's is a peek) | `GET /api/v1/sessions/:id/inbox` → `.data.{receiver,pending,unseen,messages[]}` |
| block until something unseen arrives | `GET /api/v1/sessions/:id/inbox?wait=60000` → `.data.wait.{posted,timedOut,ended}` |
| remove what you handled (receiver only, idempotent) | `POST /api/v1/sessions/:id/inbox/ack` body `{"ids":["…"]}`; 403 from anyone else |
| counts per session, never text | `GET /api/v1/agent-inbox/summary` → `.data.sessions[id].{pending,unseen,oldestUnseenAt,parked}` |
`from` and "who is the receiver" both come from the `X-Codeman-Parent-Session` header the
preamble's `CURL` sends, never from the body.
### The wait primitives
Three bounded long-polls. Shared semantics:
@@ -801,7 +822,7 @@ response echoes.
| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per account per IP) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
Case creation is **uncapped**, which is the one place restraint has to come from you:
every `quick-start` with a new `caseName` creates a real directory on the user's disk.
@@ -0,0 +1,84 @@
# Saved Codex history (offline)
Use `codeman history read` for retained local Codex JSONL transcripts. This command
works outside a managed session and never calls the HTTP API. Do not bootstrap
API credentials or require `CODEMAN_MUX` for this read-only job.
Choose exactly one selector:
```bash
codeman history read --file /path/to/rollout.jsonl
codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions
codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \
--topic 'authentication' --after 2026-10-01 --before 2026-10-24 \
--max-bytes 65536
```
`--session-id` accepts a native conversation UUID or unique prefix of at least
8 characters; identity comes from metadata, not filenames. `--root-session-id`
requires a full native UUID and includes transitive descendants found in metadata
(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or
`~/.codex/sessions` when unset; a store directory that is itself a symlink is
followed, links inside it are skipped. `--file` selects one file and does not
search for its ancestors or children.
Topic matching is a case-insensitive literal substring. Dates are inclusive
`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole
user/assistant turns; a turn is retained if it has a date match and a topic match,
which may be different messages in that turn. Tool calls/results and injected
context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and
`<environment_context>`) are omitted. Adjacent duplicate native representations
are collapsed; repeated messages from the same representation are retained.
A forked or spawned thread's file starts with a copy of its parent's history. That
copy is dropped (it is in the parent's own file), so each record belongs to the
thread that wrote it. A later `session_meta` for any thread that is not a declared
ancestor still refuses the file.
JSON is the default (`--json` or `--format json`); `--format text` prints one block
per record. `--json` together with `--format text` is an error.
Results identify source paths, native session/root IDs, timestamps, roles and phases,
plus scan counters and `complete`. `phase` is Codex's own (`commentary` or
`final_answer`) on assistant records and `null` on user records or when none was
recorded. A root ID is `null` when ancestry cannot be proven.
Report that uncertainty; never relabel the queried child as its own root.
`complete: false` reports malformed or partial records (a running Codex may be
mid-write), skipped links, unattributed files, compressed rollouts, unreadable
directories, missing ancestors, or an inherited copy whose end could not be found
or proven (including when the parent's file is present but cannot be read). It
does not prove the store includes every conversation ever created, and a filtered
result is only evidence for its selected scope.
Reads require regular UTF8 files and reject final-component symlinks, conflicting
metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that
cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB,
changing while its head is read) is counted in `unattributedFiles` instead of
failing the lookup. Codex's optional rollout compression rewrites cold rollouts to
`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose
plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that
misses names them. A compressed file passed to `--file` is refused; read a
decompressed copy instead (`zstd -dc <file> > copy.jsonl`). A selected rollout
that a running Codex is appending to is read as a snapshot up to its last complete
line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is
not a filesystem sandbox.
Budgets, each failing with an error rather than a partial answer. Every flag is
validated before the first file is read, and only plain decimal integers are
accepted:
| Flag | Bounds | Default | Maximum |
| ---- | ------ | ------- | ------- |
| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB |
| `--max-entries` | directory entries visited | 65536 | 1048576 |
| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB |
| `--max-bytes` | output, including the final newline | 8192 | 1048576 |
Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so
attributing a store costs about that much per rollout, charged to
`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex
writes `YYYY/MM/DD`). Output is never silently truncated or split into partial
JSON or turns. Narrow the store/filters or explicitly raise the relevant budget.
Missing evidence is an error, not an empty success.
This command does not inspect live panes, stage prompts, submit input or manage
sessions. For live operations return to the managed-session guard in the skill.
@@ -0,0 +1,152 @@
# Experimental bounded inspection and guarded submission
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths,
payloads and behavior may change or be removed in a minor or patch release.
Pin a compatible Codeman version when automating them. Authentication and
session ownership checks still apply.
They are also opt-in: until the user turns on **Experimental agent coordination**
in App Settings (`agentCoordinationEnabled`, off by default), every one of them
fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something
to retry or work around: tell the user, or use `send`/`read` instead.
Use these commands inside a Codeman session. They use its authenticated HTTP API.
They supplement `send`, `wait` and `read` over the same HTTP transport; no direct
tmux access is required.
## Inspect recent evidence
```bash
codeman agent inspect "$SID" --max-bytes 8192 --json
codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json
```
Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before
interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON
metadata/envelope. The default is 8192, maximum 65536.
- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call
selects recent complete records within a bounded tail window;
`omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues
after the last consumed complete record. Partial final records remain pending.
- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered
output. It has `incremental:false` and `cursor:null`. This is the fallback for
other modes, remote/container sessions, ambiguous attribution, and exhausted
attribution budgets. Repeating a snapshot can repeat evidence.
Attribution proves which file supplied the evidence, not which thread is now
active in the TUI. A launch resume that conflicts with a newly owned rollout
falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator
thread may leave launch metadata unchanged. Check `identity.conversationId`;
these reads do not provide authoritative current-thread tracking.
Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the
requesting identity, session, pane lifecycle generation
and attributed transcript, expire after 15 minutes, and disappear on server
restart. Each caller and session keeps its newest 8 (so retrying the last call
works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor
or unavailable native source returns `409 CONFLICT`. Inspect again without
`--since` to establish a new tail baseline; this may omit older evidence.
`blockedReason` explains an unfinished or oversized record. Incremental reads
never split or silently skip one. Increase the byte budget for
`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap,
use `read --tail` for a labeled terminal view or start a fresh tail baseline.
Inspect scans at most 256 KiB of record data plus bounded generation anchors.
Attribution separately caps directory entries (8192), depth (8), negative-filter
prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and
activity are observations, not proof of task completion or authorization.
## Stage, inspect, then submit once
```bash
printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt
codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json
# Review the returned stageId, sha256, expiresAt; inspect the target if needed.
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json
```
Stage types the file contents **without Enter**. The file must be regular, UTF8,
one printable line, at most 8192 bytes, with no control characters or trailing
whitespace/newline. It initially supports a local Codex composer with a recognized
footer and an exact, unwrapped line/caret capture. Before typing, stage checks
the actual captured width and composer indentation; a message that would reach
the right edge or wrap is refused without writing anything. Unknown, working, modal,
wrapped, remote/container and shell composers are refused. A customized footer or
unsupported character-width rendering can also make verification unavailable.
Self-targets are refused by the CLI.
Stage also refuses, before typing, a message that would change what Enter does:
one starting with `/` (a Codex slash command; with its command list open, Enter
would run the highlighted command instead) or `!` (a shell command), and one whose
**last** word starts with `@` (file/mention search) or `$` (skills). Those popups
follow the caret, which stage leaves at the end, so `look at @src/app.ts please`
is fine. Send slash and shell commands with `codeman agent send`, and end a
message with an ordinary word when it mentions a file. A frame showing a
suggestion popup above the composer is refused at stage and submit too.
The receipt stores a hash and ephemeral identities, never prompt content. It
expires after two minutes; capacity is 1024 live receipts. After typing, stage
re-reads the pane (every 50 ms, up to one second, since Codex repaints a little
after the keys) and issues a receipt only for a frame that shows exactly the
message with the caret after it. Any refusal after the keys went out (typing
failed or was ambiguous, the pane or input changed, or no frame verified in time)
says the message may already be in the composer: no receipt was issued. Do not
stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first.
While a submit's Enter is in flight (milliseconds, plus the moment it takes to
write input held behind it), TAGGED input to that session (`clientId` and `seq`,
which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT`
with nothing written; retry it. Untagged input, terminal keystrokes, `send-key`
and Codeman's own managed writes are held instead and written in arrival order
once the Enter is out.
Submit checks the caller, session, hash, expiry, pane lifecycle, input revision,
geometry, exact composer and caret, then sends one conditional Enter at the session's input
boundary. It first waits until 250 ms have passed since stage typed (Codex turns an
Enter that follows typed text within 120 ms into a newline), so submitting right
after stage is fine. Competing Codeman writes are held during dispatch and written
after it, except tagged input, which is refused with `409`. Browser/API/key
input and managed restarts invalidate receipts even when the visible text or pid
is restored. Direct tmux edits are outside that input serialization; captures
remain evidence at the time observed, rather than terminal isolation.
Every owned submit attempt consumes its receipt, including hash mismatch,
verification failure and delivery failure. Concurrent/repeated submits cannot
replay Enter, and there is no autonomous retry. A successful response means one
Enter was delivered; use inspection or a suitable wait signal to observe native
acceptance. A `409` or broken connection means inspect first. Do not automatically
stage/submit again or send an unguarded Enter to recover.
## Recover a stranded draft
A consumed receipt cannot be reused, and a stage refused after typing issued
none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture
(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has
accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing;
do not clear or resend anything. A transcript prompt row alone is not proof
that text remains unsent in the current composer.
Only recover an unambiguously unsent draft in an input-ready local Codex
composer: verify the complete draft, recognized footer and caret at its end in
the current frame, and ensure no other writer can edit this target during
recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear
it in the terminal instead of guessing keys. Never use `send` to overwrite a
draft: that appends and submits both prompts together.
After those checks, the known Codex line-clear chord may be sent once through
the existing raw input route, with **no Enter**. This conditional example uses
the authenticated `CURL`/`API` values loaded by §0 and the exact target id:
```bash
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}'
```
Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and
verify the composer is empty and input-ready. Only then create a **fresh** stage
receipt for the intended prompt and submit that receipt once. If the draft did
not clear, the target began working, or verification remains uncertain, stop and
ask the user; do not send an unguarded Enter or replay the old prompt.
@@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| Job | Channel |
| --- | --- |
| spawn a worker, create its case | HTTP `quick-start` (the only path) |
| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) |
| readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) |
| deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input |
| steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) |
@@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|-------------------|------|------------------------------|
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check |
Read `.data.casePath` back from the `quick-start` response and check it is where you
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
@@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup
that half is the CLI's behavior, not something this API guarantees.
- **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a
typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it
with `{"input":"\r"}` and let the worker read the junk line.
typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified
draft merely to clear it. For a refused guarded local Codex stage or submit, use the
verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft);
when the composer is unclear, ask the user to clear it in the terminal.
- The interrupted turn already burned its tokens. Interrupting early saves the rest.
- `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its
allowlist is S-Enter / C-Enter only.
@@ -764,3 +766,63 @@ Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unifie
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
### 5.15 Agent inbox
Every session has an inbox. A message posted there is stored, not typed: the receiver's
pane is untouched, no turn starts, and the text may span several lines. The receiver
reads when it is ready. Use it for results a worker hands back, for a follow-up task a
busy worker should pick up after its current one, and for anything longer than one
composer line. Use `send` when you want a turn to start now.
```bash
codeman agent post "$WORKER" "$(cat task.md)" # ONE quoted argument; prints pending/unseen
codeman agent inbox --wait --timeout 300000 # your own inbox; blocks while nothing is new
codeman agent ack <id> [<id>…] # remove what you handled, by the full id inbox printed
codeman agent inbox "$WORKER" # peek at someone else's: marks nothing
codeman agent ls # INBOX column: pending (new), parked
```
The same over HTTP: `POST /api/v1/sessions/:id/inbox {"text":…}`, `GET
/api/v1/sessions/:id/inbox?wait=<ms>`, `POST /api/v1/sessions/:id/inbox/ack {"ids":[…]}`,
`GET /api/v1/agent-inbox/summary`. The preamble's `CURL` already sends
`X-Codeman-Parent-Session`, which is how the server knows who you are.
Rules that matter:
- **Reading removes nothing.** Every read returns every pending message until you ack
it, so a crash between reading and doing loses nothing. Ack once the work is done,
not when you have read it: a sender looking at `ls` sees unacked mail as not done.
- **Only the receiver's own read counts.** Your read of your own inbox marks messages
seen (`seen: true` on the next read); reading another session's inbox is a peek and
changes nothing for it. Only the receiver can ack (403 otherwise).
- **`--wait` waits for something new.** It returns at once when there is unseen mail
and otherwise blocks until a post arrives; seen but unacked messages come back with it
and do not end the wait. Timeout is exit 2, and so is a wait the server cut short
(a restart): loop again. Exit 3 means the session list no longer has the session.
`--wait` on someone else's inbox returns at once while they have unseen mail (only
their read clears it); check `ls` instead.
- **The sender is resolved, not claimed.** `from` is the session the header resolves to,
or `null` (the user). In single-user mode that is a hint, since every local process
holds the same credentials. In multi-user mode it is checked against the owner;
an admin can still act as any session of the receiver's owner.
- **Full means refused.** 200 messages or 512 KiB per inbox, 16 KiB per message. A full
inbox answers 409 and stores nothing; the oldest is never dropped. Messages expire 12
hours after they were posted.
- **Nobody is woken.** An idle receiver does not look at its inbox by itself, and the
server never types into its pane. When `ls` shows a worker idle with unread mail and
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
## Experimental bounded inspection and guarded submission
These three commands and their HTTP routes are labeled experimental, so they may
change or be removed in minor or patch releases. They are also opt-in
(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns
it on). Use `codeman agent inspect` for bounded evidence and incremental local
Codex cursors.
Use `codeman agent stage --message-file` followed by one hash-confirmed
`codeman agent submit --stage-id --confirm-sha` when the exact local Codex
composer and caret can be verified. Other targets provide labeled snapshots or
refuse guarded input. See [inspection and staging](inspection-and-staging.md)
for budgets, receipt expiry, conflict handling and supported composers.
+2
View File
@@ -120,6 +120,7 @@ run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --m
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
run('minify header-divider.js', 'npx esbuild dist/web/public/header-divider.js --minify --outfile=dist/web/public/header-divider.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
@@ -149,6 +150,7 @@ console.log('\n[build] content-hash cache busting');
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
'header-divider.js',
'terminal-ui.js',
'respawn-ui.js',
'ralph-panel.js',
+30 -8
View File
@@ -8,12 +8,19 @@ description: >-
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
another session, or start and manage workers. Only usable inside a Codeman-managed
session (CODEMAN_MUX=1); refuse to act otherwise.
another session, start and manage workers, or retrieve saved local Codex history
by file/native ID or root and descendants with topic/date filters. Live API
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
reads do not require a server or managed session.
---
# Driving Codeman from inside a session
For **saved Codex conversation history**, use `codeman history read` and
[reference/history.md](reference/history.md). This is a read-only local command:
route to it **before §0**, including outside a managed session. It needs no API
URL, credentials or running server. All live API operations below retain §0.
You are an agent running inside a Codeman-managed terminal session. Codeman is the
server that spawned you; its HTTP API can start, prompt, watch, and delete other
sessions.
@@ -30,20 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver
worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Experimental bounded inspection and guarded stage/submit load only when needed from
[reference/inspection-and-staging.md](reference/inspection-and-staging.md).
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`,
`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
no guessed URL) enforced in code, and it is also your own way through when this
machine has no `jq` for the preamble below.
For an existing project, use one command:
`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and
no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A
positional case keeps quick-start behavior and cannot be combined with
`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4
rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would
land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive.
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
@@ -119,9 +139,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -602,6 +622,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
| leave a message a session reads when it is ready, any mode, multi-line | `codeman agent post <id> "…"` (`POST .../inbox`). Nothing is typed into its pane and no turn starts; the receiver reads with `codeman agent inbox --wait` and removes with `codeman agent ack <id>`. An idle receiver never looks on its own: `codeman agent ls` shows unread mail, then `send` it one line | [§5.15](reference/verbs.md#515-agent-inbox) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -705,6 +726,7 @@ these**; open the one row you actually hit.
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
## 6. Setup and auth
+3 -3
View File
@@ -18,9 +18,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
+24 -3
View File
@@ -35,8 +35,13 @@ only and can never drive a session.
with a parse error and `.errorCode` is simply absent (see
[symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one
IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a
failing credential in a loop**: you will lock the address out of the login path for
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user
mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and each account allows 10 failed
guesses per IP; both are checked before the password, so past either even a correct
password from that address gets the 429 (for the per-account one, only for that
account). From the 10th failed guess on, an account's wrong guesses also get the 429
from every address, but a correct password still logs in. **Never retry a failing
credential in a loop**: you will lock the address out of the login path for
everything, including the user's browser through a tunnel (tunneled traffic arrives as
127.0.0.1, so one bucket covers it all).
@@ -608,6 +613,22 @@ Approving a permission dialog on your own is exactly the laundering this skill f
can access, which includes the user's own working sessions. An approval belonging to one
of those is something you **report**, never something you answer.
### Agent inbox: hand a worker text without typing into its pane
Stored, not typed: the receiver reads it when it asks. Semantics, CLI form and the
receiver loop: `verbs.md` §5.15.
| Task | Call |
|------|------|
| post to a session's inbox | `POST /api/v1/sessions/:id/inbox` body `{"text":"…"}` → `.data.message.{id,from}`; 409 = inbox full, nothing stored |
| read (yours marks seen, anyone else's is a peek) | `GET /api/v1/sessions/:id/inbox` → `.data.{receiver,pending,unseen,messages[]}` |
| block until something unseen arrives | `GET /api/v1/sessions/:id/inbox?wait=60000` → `.data.wait.{posted,timedOut,ended}` |
| remove what you handled (receiver only, idempotent) | `POST /api/v1/sessions/:id/inbox/ack` body `{"ids":["…"]}`; 403 from anyone else |
| counts per session, never text | `GET /api/v1/agent-inbox/summary` → `.data.sessions[id].{pending,unseen,oldestUnseenAt,parked}` |
`from` and "who is the receiver" both come from the `X-Codeman-Parent-Session` header the
preamble's `CURL` sends, never from the body.
### The wait primitives
Three bounded long-polls. Shared semantics:
@@ -801,7 +822,7 @@ response echoes.
| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per account per IP) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
Case creation is **uncapped**, which is the one place restraint has to come from you:
every `quick-start` with a new `caseName` creates a real directory on the user's disk.
+84
View File
@@ -0,0 +1,84 @@
# Saved Codex history (offline)
Use `codeman history read` for retained local Codex JSONL transcripts. This command
works outside a managed session and never calls the HTTP API. Do not bootstrap
API credentials or require `CODEMAN_MUX` for this read-only job.
Choose exactly one selector:
```bash
codeman history read --file /path/to/rollout.jsonl
codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions
codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \
--topic 'authentication' --after 2026-10-01 --before 2026-10-24 \
--max-bytes 65536
```
`--session-id` accepts a native conversation UUID or unique prefix of at least
8 characters; identity comes from metadata, not filenames. `--root-session-id`
requires a full native UUID and includes transitive descendants found in metadata
(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or
`~/.codex/sessions` when unset; a store directory that is itself a symlink is
followed, links inside it are skipped. `--file` selects one file and does not
search for its ancestors or children.
Topic matching is a case-insensitive literal substring. Dates are inclusive
`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole
user/assistant turns; a turn is retained if it has a date match and a topic match,
which may be different messages in that turn. Tool calls/results and injected
context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and
`<environment_context>`) are omitted. Adjacent duplicate native representations
are collapsed; repeated messages from the same representation are retained.
A forked or spawned thread's file starts with a copy of its parent's history. That
copy is dropped (it is in the parent's own file), so each record belongs to the
thread that wrote it. A later `session_meta` for any thread that is not a declared
ancestor still refuses the file.
JSON is the default (`--json` or `--format json`); `--format text` prints one block
per record. `--json` together with `--format text` is an error.
Results identify source paths, native session/root IDs, timestamps, roles and phases,
plus scan counters and `complete`. `phase` is Codex's own (`commentary` or
`final_answer`) on assistant records and `null` on user records or when none was
recorded. A root ID is `null` when ancestry cannot be proven.
Report that uncertainty; never relabel the queried child as its own root.
`complete: false` reports malformed or partial records (a running Codex may be
mid-write), skipped links, unattributed files, compressed rollouts, unreadable
directories, missing ancestors, or an inherited copy whose end could not be found
or proven (including when the parent's file is present but cannot be read). It
does not prove the store includes every conversation ever created, and a filtered
result is only evidence for its selected scope.
Reads require regular UTF8 files and reject final-component symlinks, conflicting
metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that
cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB,
changing while its head is read) is counted in `unattributedFiles` instead of
failing the lookup. Codex's optional rollout compression rewrites cold rollouts to
`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose
plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that
misses names them. A compressed file passed to `--file` is refused; read a
decompressed copy instead (`zstd -dc <file> > copy.jsonl`). A selected rollout
that a running Codex is appending to is read as a snapshot up to its last complete
line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is
not a filesystem sandbox.
Budgets, each failing with an error rather than a partial answer. Every flag is
validated before the first file is read, and only plain decimal integers are
accepted:
| Flag | Bounds | Default | Maximum |
| ---- | ------ | ------- | ------- |
| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB |
| `--max-entries` | directory entries visited | 65536 | 1048576 |
| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB |
| `--max-bytes` | output, including the final newline | 8192 | 1048576 |
Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so
attributing a store costs about that much per rollout, charged to
`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex
writes `YYYY/MM/DD`). Output is never silently truncated or split into partial
JSON or turns. Narrow the store/filters or explicitly raise the relevant budget.
Missing evidence is an error, not an empty success.
This command does not inspect live panes, stage prompts, submit input or manage
sessions. For live operations return to the managed-session guard in the skill.
@@ -0,0 +1,152 @@
# Experimental bounded inspection and guarded submission
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths,
payloads and behavior may change or be removed in a minor or patch release.
Pin a compatible Codeman version when automating them. Authentication and
session ownership checks still apply.
They are also opt-in: until the user turns on **Experimental agent coordination**
in App Settings (`agentCoordinationEnabled`, off by default), every one of them
fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something
to retry or work around: tell the user, or use `send`/`read` instead.
Use these commands inside a Codeman session. They use its authenticated HTTP API.
They supplement `send`, `wait` and `read` over the same HTTP transport; no direct
tmux access is required.
## Inspect recent evidence
```bash
codeman agent inspect "$SID" --max-bytes 8192 --json
codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json
```
Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before
interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON
metadata/envelope. The default is 8192, maximum 65536.
- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call
selects recent complete records within a bounded tail window;
`omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues
after the last consumed complete record. Partial final records remain pending.
- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered
output. It has `incremental:false` and `cursor:null`. This is the fallback for
other modes, remote/container sessions, ambiguous attribution, and exhausted
attribution budgets. Repeating a snapshot can repeat evidence.
Attribution proves which file supplied the evidence, not which thread is now
active in the TUI. A launch resume that conflicts with a newly owned rollout
falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator
thread may leave launch metadata unchanged. Check `identity.conversationId`;
these reads do not provide authoritative current-thread tracking.
Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the
requesting identity, session, pane lifecycle generation
and attributed transcript, expire after 15 minutes, and disappear on server
restart. Each caller and session keeps its newest 8 (so retrying the last call
works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor
or unavailable native source returns `409 CONFLICT`. Inspect again without
`--since` to establish a new tail baseline; this may omit older evidence.
`blockedReason` explains an unfinished or oversized record. Incremental reads
never split or silently skip one. Increase the byte budget for
`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap,
use `read --tail` for a labeled terminal view or start a fresh tail baseline.
Inspect scans at most 256 KiB of record data plus bounded generation anchors.
Attribution separately caps directory entries (8192), depth (8), negative-filter
prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and
activity are observations, not proof of task completion or authorization.
## Stage, inspect, then submit once
```bash
printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt
codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json
# Review the returned stageId, sha256, expiresAt; inspect the target if needed.
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json
```
Stage types the file contents **without Enter**. The file must be regular, UTF8,
one printable line, at most 8192 bytes, with no control characters or trailing
whitespace/newline. It initially supports a local Codex composer with a recognized
footer and an exact, unwrapped line/caret capture. Before typing, stage checks
the actual captured width and composer indentation; a message that would reach
the right edge or wrap is refused without writing anything. Unknown, working, modal,
wrapped, remote/container and shell composers are refused. A customized footer or
unsupported character-width rendering can also make verification unavailable.
Self-targets are refused by the CLI.
Stage also refuses, before typing, a message that would change what Enter does:
one starting with `/` (a Codex slash command; with its command list open, Enter
would run the highlighted command instead) or `!` (a shell command), and one whose
**last** word starts with `@` (file/mention search) or `$` (skills). Those popups
follow the caret, which stage leaves at the end, so `look at @src/app.ts please`
is fine. Send slash and shell commands with `codeman agent send`, and end a
message with an ordinary word when it mentions a file. A frame showing a
suggestion popup above the composer is refused at stage and submit too.
The receipt stores a hash and ephemeral identities, never prompt content. It
expires after two minutes; capacity is 1024 live receipts. After typing, stage
re-reads the pane (every 50 ms, up to one second, since Codex repaints a little
after the keys) and issues a receipt only for a frame that shows exactly the
message with the caret after it. Any refusal after the keys went out (typing
failed or was ambiguous, the pane or input changed, or no frame verified in time)
says the message may already be in the composer: no receipt was issued. Do not
stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first.
While a submit's Enter is in flight (milliseconds, plus the moment it takes to
write input held behind it), TAGGED input to that session (`clientId` and `seq`,
which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT`
with nothing written; retry it. Untagged input, terminal keystrokes, `send-key`
and Codeman's own managed writes are held instead and written in arrival order
once the Enter is out.
Submit checks the caller, session, hash, expiry, pane lifecycle, input revision,
geometry, exact composer and caret, then sends one conditional Enter at the session's input
boundary. It first waits until 250 ms have passed since stage typed (Codex turns an
Enter that follows typed text within 120 ms into a newline), so submitting right
after stage is fine. Competing Codeman writes are held during dispatch and written
after it, except tagged input, which is refused with `409`. Browser/API/key
input and managed restarts invalidate receipts even when the visible text or pid
is restored. Direct tmux edits are outside that input serialization; captures
remain evidence at the time observed, rather than terminal isolation.
Every owned submit attempt consumes its receipt, including hash mismatch,
verification failure and delivery failure. Concurrent/repeated submits cannot
replay Enter, and there is no autonomous retry. A successful response means one
Enter was delivered; use inspection or a suitable wait signal to observe native
acceptance. A `409` or broken connection means inspect first. Do not automatically
stage/submit again or send an unguarded Enter to recover.
## Recover a stranded draft
A consumed receipt cannot be reused, and a stage refused after typing issued
none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture
(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has
accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing;
do not clear or resend anything. A transcript prompt row alone is not proof
that text remains unsent in the current composer.
Only recover an unambiguously unsent draft in an input-ready local Codex
composer: verify the complete draft, recognized footer and caret at its end in
the current frame, and ensure no other writer can edit this target during
recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear
it in the terminal instead of guessing keys. Never use `send` to overwrite a
draft: that appends and submits both prompts together.
After those checks, the known Codex line-clear chord may be sent once through
the existing raw input route, with **no Enter**. This conditional example uses
the authenticated `CURL`/`API` values loaded by §0 and the exact target id:
```bash
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}'
```
Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and
verify the composer is empty and input-ready. Only then create a **fresh** stage
receipt for the intended prompt and submit that receipt once. If the draft did
not clear, the target began working, or verification remains uncertain, stop and
ask the user; do not send an unguarded Enter or replay the old prompt.
+1 -1
View File
@@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| Job | Channel |
| --- | --- |
| spawn a worker, create its case | HTTP `quick-start` (the only path) |
| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) |
| readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) |
| deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input |
| steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) |
+65 -3
View File
@@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|-------------------|------|------------------------------|
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check |
Read `.data.casePath` back from the `quick-start` response and check it is where you
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
@@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup
that half is the CLI's behavior, not something this API guarantees.
- **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a
typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it
with `{"input":"\r"}` and let the worker read the junk line.
typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified
draft merely to clear it. For a refused guarded local Codex stage or submit, use the
verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft);
when the composer is unclear, ask the user to clear it in the terminal.
- The interrupted turn already burned its tokens. Interrupting early saves the rest.
- `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its
allowlist is S-Enter / C-Enter only.
@@ -764,3 +766,63 @@ Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unifie
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
### 5.15 Agent inbox
Every session has an inbox. A message posted there is stored, not typed: the receiver's
pane is untouched, no turn starts, and the text may span several lines. The receiver
reads when it is ready. Use it for results a worker hands back, for a follow-up task a
busy worker should pick up after its current one, and for anything longer than one
composer line. Use `send` when you want a turn to start now.
```bash
codeman agent post "$WORKER" "$(cat task.md)" # ONE quoted argument; prints pending/unseen
codeman agent inbox --wait --timeout 300000 # your own inbox; blocks while nothing is new
codeman agent ack <id> [<id>…] # remove what you handled, by the full id inbox printed
codeman agent inbox "$WORKER" # peek at someone else's: marks nothing
codeman agent ls # INBOX column: pending (new), parked
```
The same over HTTP: `POST /api/v1/sessions/:id/inbox {"text":…}`, `GET
/api/v1/sessions/:id/inbox?wait=<ms>`, `POST /api/v1/sessions/:id/inbox/ack {"ids":[…]}`,
`GET /api/v1/agent-inbox/summary`. The preamble's `CURL` already sends
`X-Codeman-Parent-Session`, which is how the server knows who you are.
Rules that matter:
- **Reading removes nothing.** Every read returns every pending message until you ack
it, so a crash between reading and doing loses nothing. Ack once the work is done,
not when you have read it: a sender looking at `ls` sees unacked mail as not done.
- **Only the receiver's own read counts.** Your read of your own inbox marks messages
seen (`seen: true` on the next read); reading another session's inbox is a peek and
changes nothing for it. Only the receiver can ack (403 otherwise).
- **`--wait` waits for something new.** It returns at once when there is unseen mail
and otherwise blocks until a post arrives; seen but unacked messages come back with it
and do not end the wait. Timeout is exit 2, and so is a wait the server cut short
(a restart): loop again. Exit 3 means the session list no longer has the session.
`--wait` on someone else's inbox returns at once while they have unseen mail (only
their read clears it); check `ls` instead.
- **The sender is resolved, not claimed.** `from` is the session the header resolves to,
or `null` (the user). In single-user mode that is a hint, since every local process
holds the same credentials. In multi-user mode it is checked against the owner;
an admin can still act as any session of the receiver's owner.
- **Full means refused.** 200 messages or 512 KiB per inbox, 16 KiB per message. A full
inbox answers 409 and stores nothing; the oldest is never dropped. Messages expire 12
hours after they were posted.
- **Nobody is woken.** An idle receiver does not look at its inbox by itself, and the
server never types into its pane. When `ls` shows a worker idle with unread mail and
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
## Experimental bounded inspection and guarded submission
These three commands and their HTTP routes are labeled experimental, so they may
change or be removed in minor or patch releases. They are also opt-in
(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns
it on). Use `codeman agent inspect` for bounded evidence and incremental local
Codex cursors.
Use `codeman agent stage --message-file` followed by one hash-confirmed
`codeman agent submit --stage-id --confirm-sha` when the exact local Codex
composer and caret can be verified. Other targets provide labeled snapshots or
refuse guarded input. See [inspection and staging](inspection-and-staging.md)
for budgets, receipt expiry, conflict handling and supported composers.
+275
View File
@@ -0,0 +1,275 @@
/**
* @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses
* newest-mtime, prompt timestamps or cwd alone as proof of session ownership.
* The first call walks the store under explicit budgets; the attributed file is
* then remembered per session and only re-verified (plus a scan of the date
* directories not older than it) on later calls, since codex never prunes
* `~/.codex/sessions`.
*/
import { open, opendir, realpath } from 'node:fs/promises';
import { probePathKind } from './utils/bounded-path-probe.js';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { LRUMap } from './utils/lru-map.js';
const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
const HEAD_BYTES = 131072;
const PREFIX_SCAN_BYTES = 8 * 1024 * 1024;
export interface CodexAttributionOptions {
codexHome: string;
sessionId: string;
workingDir: string;
resumeSessionId?: string;
}
export interface CodexAttributionBudget {
maxEntries: number;
maxBytes: number;
/** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */
maxPrefixBytes?: number;
}
interface Metadata {
id?: string;
cwd?: string;
originator?: string;
}
async function canonical(path: string): Promise<string | undefined> {
if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined;
try {
return await realpath(path);
} catch {
return undefined;
}
}
/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */
export function resolveEffectiveCodexHome(
sessionHome: string | undefined,
workingDir: string,
env: Readonly<Record<string, string | undefined>> = process.env
): string {
const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex'));
return path;
}
/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */
function strictMetadata(bytes: Buffer): Metadata | undefined {
const rows = bytes.toString('utf8').split('\n');
rows.pop();
let found: Metadata | undefined;
for (const line of rows) {
try {
const row = JSON.parse(line) as { type?: string; payload?: Metadata };
if (row.type !== 'session_meta') continue;
if (
found ||
typeof row.payload?.id !== 'string' ||
typeof row.payload.cwd !== 'string' ||
(row.payload.originator !== undefined && typeof row.payload.originator !== 'string')
)
return undefined;
found = row.payload;
} catch {
/* Incomplete or malformed rows are not identity evidence. */
}
}
return found;
}
/**
* The rollout a previous call attributed, per session. A later call re-verifies only
* this file's head (same `dev:ino`, same strict metadata) and then scans just the
* directories not older than its own for a second candidate (codex `/new`, or a new
* owned rollout beside a configured resume), instead of re-reading the whole store,
* which codex never prunes. A head that no longer verifies drops the entry and falls
* back to the full walk; a failed incremental scan drops it and answers unattributed,
* since the full walk reads a superset of that scan. Bounded: one entry per live
* session is the expected size.
*/
interface AttributionMemo {
id: string;
path: string;
dev: number;
ino: number;
meta: Metadata;
/** Directory components of `path` below `sessions/`, used to prune older siblings. */
dirParts: string[];
}
const attributionMemo = new LRUMap<string, AttributionMemo>({ maxSize: 1024 });
/** Test-only: forget every remembered attribution. */
export function __clearCodexAttributionMemo(): void {
attributionMemo.clear();
}
/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */
const PREFIX_FIRST_BYTES = 1024;
/** Fallback read when the first window holds no complete `originator` field. */
const PREFIX_MAX_BYTES = 4096;
const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/;
/**
* Is a date-shaped directory name older than the remembered one at the same depth?
* Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else
* is never pruned, so an unexpected layout costs a wider scan, not a missed rollout.
*/
function olderDatePart(name: string, memo: string | undefined): boolean {
if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false;
return Number(name) < Number(memo);
}
/**
* A configured resume and a newly owned rollout conflict rather than pinning an
* old launch forever after /new. TUI resumes of foreign-originator threads cannot
* be authoritatively tracked from this metadata; this returns attributed evidence.
*/
export async function findExactCodexConversation(
options: CodexAttributionOptions,
budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 }
): Promise<{ id: string; path: string } | undefined> {
const workspace = await canonical(options.workingDir);
const home = await canonical(options.codexHome);
if (!workspace || !home) return undefined;
const sessions = join(home, 'sessions');
if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined;
const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0');
const remembered = attributionMemo.get(key);
if (remembered) {
const verified = await verifyRemembered(remembered, workspace, budget);
if (verified) {
const others = await scanCandidates(sessions, options, budget, remembered.dirParts);
if (others && others.length === 1 && others[0].path === remembered.path) {
return { id: remembered.id, path: remembered.path };
}
}
attributionMemo.delete(key);
// A file that no longer verifies (replaced, rewritten, gone) gets one full walk
// now. A verified file whose incremental scan failed does not: that scan reads a
// subset of what a full walk reads under the same budget, so a second candidate,
// an exhausted budget or an unreadable entry it hit is one the full walk would hit
// too, and walking the whole never-pruned store could only end the same way.
if (verified) return undefined;
}
const candidates = await scanCandidates(sessions, options, budget);
if (!candidates || candidates.length !== 1) return undefined;
const candidate = candidates[0];
if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined;
const handle = await open(candidate.path, 'r').catch(() => undefined);
if (!handle) return undefined;
try {
const info = await handle.stat();
const bytes = Buffer.alloc(HEAD_BYTES);
const read = await handle.read(bytes, 0, bytes.length, 0);
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
if (
!meta ||
meta.id !== candidate.id ||
meta.cwd !== candidate.meta.cwd ||
meta.originator !== candidate.meta.originator
)
return undefined;
attributionMemo.set(key, {
id: candidate.id,
path: candidate.path,
dev: info.dev,
ino: info.ino,
meta,
dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean),
});
return { id: candidate.id, path: candidate.path };
} finally {
await handle.close();
}
}
/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */
async function verifyRemembered(
memo: AttributionMemo,
workspace: string,
budget: CodexAttributionBudget
): Promise<boolean> {
if (HEAD_BYTES > budget.maxBytes) return false;
if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false;
const handle = await open(memo.path, 'r').catch(() => undefined);
if (!handle) return false;
try {
const info = await handle.stat();
if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false;
const bytes = Buffer.alloc(HEAD_BYTES);
const read = await handle.read(bytes, 0, bytes.length, 0);
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator;
} catch {
return false;
} finally {
await handle.close();
}
}
/**
* Every rollout under `sessions` whose strict metadata names this session (or the
* configured resume id), or undefined when the walk hit a bound or an unreadable
* entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped
* directories older than those components are skipped.
*/
async function scanCandidates(
sessions: string,
options: CodexAttributionOptions,
budget: CodexAttributionBudget,
notOlderThan?: string[]
): Promise<Array<{ id: string; path: string; meta: Metadata }> | undefined> {
// `onBoundary`: every ancestor so far equals the remembered path, so pruning applies.
const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }];
const candidates: Array<{ id: string; path: string; meta: Metadata }> = [];
const originator = `codeman_${options.sessionId}`;
const originatorJson = JSON.stringify(originator);
const explicit = options.resumeSessionId;
let entries = 0;
let prefixes = 0;
const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES);
let scanned = HEAD_BYTES; // Reserve an independent final verification.
while (directories.length) {
const dir = directories.pop()!;
const handle = await opendir(dir.path).catch(() => undefined);
if (!handle) return undefined;
for await (const entry of handle) {
if (++entries > budget.maxEntries) return undefined;
const path = join(dir.path, entry.name);
if (entry.isDirectory()) {
if (dir.depth >= 8) return undefined;
const part = notOlderThan?.[dir.depth];
if (dir.onBoundary && olderDatePart(entry.name, part)) continue;
directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part });
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
const id = entry.name.match(UUID_AT_END)?.[1];
if (!id) continue; // no rollout id in the name: never a candidate, read nothing
const file = await open(path, 'r').catch(() => undefined);
if (!file) return undefined;
try {
const info = await file.stat();
if (!info.isFile()) return undefined;
if (id !== explicit) {
// Cheap negative filter only; a match still needs full parsed metadata.
// A short first window decides when it already holds the whole
// `originator` field; only otherwise is the wider window read.
let window = Math.min(info.size, PREFIX_FIRST_BYTES);
let prefix = '';
for (;;) {
if (prefixes + window > prefixLimit) return undefined;
prefixes += window;
const buffer = Buffer.alloc(window);
const first = await file.read(buffer, 0, window, 0);
prefix = buffer.subarray(0, first.bytesRead).toString('utf8');
if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break;
const wider = Math.min(info.size, PREFIX_MAX_BYTES);
if (wider <= window) break;
window = wider;
}
if (!prefix.includes(originatorJson)) continue;
}
const size = Math.min(info.size, HEAD_BYTES);
if (scanned + size > budget.maxBytes) return undefined;
scanned += size;
const bytes = Buffer.alloc(size);
const read = await file.read(bytes, 0, size, 0);
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
if (!meta || meta.id !== id) continue;
if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta });
} finally {
await file.close();
}
}
}
}
return candidates;
}
+208
View File
@@ -0,0 +1,208 @@
/**
* @fileoverview Bounded append-only JSONL evidence reads for agent inspection.
* Opaque, expiring server-side cursors contain no public filesystem paths. A cursor
* binds the caller, pane generation, attributed conversation and opened file identity.
* Complete records only; an oversized row stalls explicitly rather than losing evidence.
*/
import { open, type FileHandle } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { isUtf8 } from 'node:buffer';
export const INSPECT_MAX_BYTES = 65536;
export const INSPECT_SCAN_BYTES = 262144;
export interface InspectionScope {
owner: string;
sessionId: string;
generation: string;
conversationId: string;
}
interface CursorState {
scope: string;
/** Owner and session only: the unit the per-scope cap counts. */
holder: string;
path: string;
identity: string;
offset: number;
observedSize: number;
modifiedAt: number;
changedAt: number;
anchor: string;
expiresAt: number;
}
export class InspectionConflict extends Error {}
/** UTF8-safe suffix, also used for explicitly non-incremental pane evidence. */
export function boundedUtf8Tail(text: string, maxBytes: number): string {
// A UTF8 suffix fits within this many UTF16 units. Include a pair of extra
// units so slicing cannot introduce a surrogate replacement inside the result.
const bytes = Buffer.from(text.slice(-maxBytes - 2));
let start = Math.max(0, bytes.length - maxBytes);
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
return bytes.subarray(start).toString('utf8');
}
async function anchor(handle: FileHandle, offset: number): Promise<string> {
const count = Math.min(offset, 4096);
const head = Buffer.alloc(count);
const tail = Buffer.alloc(count);
await handle.read(head, 0, count, 0);
await handle.read(tail, 0, count, Math.max(0, offset - count));
return createHash('sha256').update(head).update(tail).digest('hex');
}
/** Cursors kept per owner and session; older ones are retired first. */
export const INSPECT_CURSORS_PER_SCOPE = 8;
export class InspectionReader {
private readonly cursors = new Map<string, CursorState>();
constructor(
private readonly now = Date.now,
private readonly capacity = 1024,
private readonly ttlMs = 900000,
private readonly perScope = INSPECT_CURSORS_PER_SCOPE
) {}
async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) {
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > INSPECT_MAX_BYTES)
throw new Error('Invalid byte budget');
const scopeKey = JSON.stringify(scope);
const previous = since ? this.cursors.get(since) : undefined;
if (
since &&
(!previous || previous.expiresAt <= this.now() || previous.scope !== scopeKey || previous.path !== path)
) {
throw new InspectionConflict('Invalid or expired inspection cursor; inspect without since to start again');
}
const handle = await open(path, 'r');
try {
const before = await handle.stat();
if (!before.isFile()) throw new InspectionConflict('Inspection source is not a regular file');
const identity = `${before.dev}:${before.ino}:${before.birthtimeMs}`;
let offset = previous?.offset ?? Math.max(0, before.size - INSPECT_SCAN_BYTES + 1);
if (
previous &&
(identity !== previous.identity ||
before.size < previous.observedSize ||
(before.size === previous.observedSize &&
(before.mtimeMs !== previous.modifiedAt || before.ctimeMs !== previous.changedAt)) ||
(await anchor(handle, offset)) !== previous.anchor)
) {
throw new InspectionConflict('Inspection cursor source changed; inspect without since to start again');
}
let startsAtBoundary = offset === 0;
if (!previous && offset > 0) {
const preceding = Buffer.alloc(1);
await handle.read(preceding, 0, 1, offset - 1);
startsAtBoundary = preceding[0] === 10;
}
const scan = Buffer.alloc(
Math.min(INSPECT_SCAN_BYTES - (!previous && offset > 0 ? 1 : 0), Math.max(0, before.size - offset))
);
const { bytesRead } = await handle.read(scan, 0, scan.length, offset);
let selectedStart = 0;
let consumed = 0;
let blockedReason: string | undefined;
let omittedBeforeBytes = 0;
if (!previous) {
const leading = startsAtBoundary ? 0 : scan.indexOf(10) + 1;
const completeEnd = scan.lastIndexOf(10, bytesRead - 1) + 1;
if (!startsAtBoundary && leading === 0) {
// The tail lies wholly inside one unfinished oversized record. Its
// beginning is not known: retain the known file boundary rather than
// manufacture a cursor pointing into a JSON/UTF8 fragment.
offset = 0;
blockedReason = 'record-exceeds-scan-limit';
} else {
selectedStart = completeEnd;
consumed = completeEnd;
while (selectedStart > leading) {
const recordStart = Math.max(leading, scan.lastIndexOf(10, selectedStart - 2) + 1);
if (completeEnd - recordStart > maxBytes) {
if (selectedStart === completeEnd) {
selectedStart = recordStart;
consumed = recordStart;
blockedReason = 'record-exceeds-output-budget';
}
break;
}
selectedStart = recordStart;
}
if (completeEnd < bytesRead && !blockedReason) blockedReason = 'partial-record';
// If the only known boundary is the end of a skipped leading row,
// the remainder is a partial record and stays pending there.
if (completeEnd < leading) selectedStart = consumed = leading;
omittedBeforeBytes = offset + selectedStart;
}
} else {
while (consumed < bytesRead) {
const end = scan.indexOf(10, consumed);
if (end < 0) {
blockedReason = bytesRead === INSPECT_SCAN_BYTES ? 'record-exceeds-scan-limit' : 'partial-record';
break;
}
if (end + 1 > maxBytes) {
if (consumed === 0) blockedReason = 'record-exceeds-output-budget';
break;
}
consumed = end + 1;
}
}
const evidenceBytes = scan.subarray(selectedStart, consumed);
if (!isUtf8(evidenceBytes)) throw new InspectionConflict('Inspection source contains invalid UTF8');
for (const row of evidenceBytes.toString('utf8').split('\n').slice(0, -1)) {
try {
JSON.parse(row);
} catch {
throw new InspectionConflict('Inspection source contains invalid JSONL');
}
}
const after = await handle.stat();
if (
after.size < before.size ||
after.ino !== before.ino ||
(after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs))
)
throw new InspectionConflict('Inspection source changed during read');
const nextAnchor = await anchor(handle, offset + consumed);
const cursor = randomUUID();
// No await between capacity enforcement and insertion: concurrent reads
// must not all reserve the same remaining slot while hashing anchors.
// Each owner and session keeps only its newest cursors, so one agent polling in
// a loop retires its own oldest ones instead of pushing every other caller's out
// of the shared map. The cursor just used stays among the newest, so a retry of
// the last call still works. The global capacity is only a backstop.
const holder = JSON.stringify([scope.owner, scope.sessionId]);
const mine: string[] = [];
for (const [key, value] of this.cursors) {
if (value.expiresAt <= this.now()) this.cursors.delete(key);
else if (value.holder === holder) mine.push(key);
}
while (mine.length >= this.perScope) this.cursors.delete(mine.shift()!);
while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!);
this.cursors.set(cursor, {
scope: scopeKey,
holder,
path,
identity,
offset: offset + consumed,
observedSize: before.size,
modifiedAt: before.mtimeMs,
changedAt: before.ctimeMs,
anchor: nextAnchor,
expiresAt: this.now() + this.ttlMs,
});
return {
evidence: evidenceBytes.toString('utf8'),
cursor,
omittedBeforeBytes,
truncated: omittedBeforeBytes > 0 || offset + consumed < before.size,
blockedReason,
scannedBytes: bytesRead,
};
} finally {
await handle.close();
}
}
clear(): void {
this.cursors.clear();
}
}
+356
View File
@@ -0,0 +1,356 @@
/**
* @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is
* retained: only caller/session identity, lifecycle/input revisions and SHA-256.
* A receipt is consumed synchronously before verification/delivery, including failures.
*/
import { createHash, randomUUID } from 'node:crypto';
import { stripAnsi } from './utils/regex-patterns.js';
import { visibleWidth } from './tui/tui-ansi.js';
import { compileVersionRegex } from './config/cli-registry/patterns.js';
import type { CliEntry } from './config/cli-registry/types.js';
/**
* The screen chrome a guarded composer read relies on, all from the CLI registry
* (`capabilities.workDetect`): the composer glyph, the pattern every row below an
* idle composer must match, and what opens a popup or turns Enter into a command.
* Nothing here names a CLI; a CLI takes part in guarded input by declaring
* `composerFooterLine`.
*/
export interface GuardedComposerChrome {
promptGlyph: string;
footerLine: RegExp;
/** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */
popupLine?: RegExp;
/** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */
commandPrefixes?: string;
/** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */
completionSigils?: string;
}
/** The chrome a registry entry declares, or null (no guarded input for that CLI). */
export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null {
const detect = entry?.capabilities.workDetect;
if (!detect?.promptGlyph || !detect.composerFooterLine) return null;
// Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal.
const footerLine = compileVersionRegex(detect.composerFooterLine);
if (!footerLine) return null;
// A declared popup pattern the guard refuses is no chrome at all: dropping it would
// silently read a popup frame as an idle composer.
const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined;
if (popupLine === null) return null;
return {
promptGlyph: detect.promptGlyph,
footerLine,
...(popupLine ? { popupLine } : {}),
...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}),
...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}),
};
}
/**
* Why a message cannot be staged into this composer, or null. Typing it would change
* what Enter does rather than what it sends: a draft starting with a command prefix
* (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a
* prompt, and a last word starting with a completion sigil (codex: `@` file and
* mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter
* would accept. Checked before anything is typed. Only the LAST word matters for the
* sigils because the caret is at the end of the message and the popup follows the
* caret; a sigil word earlier in the message opens nothing.
*/
export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null {
const first = message.trimStart().charAt(0);
if (first && chrome.commandPrefixes?.includes(first))
return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`;
const last = message.split(/\s/).at(-1)?.charAt(0);
if (last && chrome.completionSigils?.includes(last))
return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``;
return null;
}
/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */
const POPUP_WINDOW_ROWS = 12;
/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */
const POPUP_GAP_ROWS = 3;
const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const composerRow = (chrome: GuardedComposerChrome) =>
new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`);
export const STAGE_MAX_BYTES = 8192;
export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex');
export interface GuardedInputSnapshot {
generation: string;
revision: number;
composerSha: string;
/** Actual captured pane width minus the rendered composer prefix (including indentation). */
availableColumns: number;
}
export interface GuardedInputTarget {
id: string;
/** The session's composer chrome, read before typing for what a message would trigger. */
chrome(): GuardedComposerChrome | null;
snapshot(): GuardedInputSnapshot | null;
write(text: string): Promise<boolean>;
submit(expected: GuardedInputSnapshot): Promise<boolean>;
}
interface Receipt extends GuardedInputSnapshot {
owner: string;
sessionId: string;
expiresAt: number;
sha256: string;
/** When the staged keys went out, for `SUBMIT_QUIET_MS`. */
typedAt: number;
}
export class GuardedInputConflict extends Error {}
/**
* How long stage keeps reading the pane after typing for a frame that shows the
* message, and how often. A capture taken right after the keys land is often a
* frame from before them: codex repaints its composer 30 to 60 ms after input
* arrives (measured on a live 0.162.0 pane), so the first read can show the old,
* empty composer, or nothing provable at all. A second of 50 ms reads covers that
* many times over and still answers the caller quickly.
*/
export const STAGE_SETTLE_TIMEOUT_MS = 1000;
export const STAGE_SETTLE_INTERVAL_MS = 50;
/**
* The least time between the staged keys and the conditional Enter. Stage types its
* text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a
* paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather
* than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/
* paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers
* would land inside it and be told one Enter was delivered while codex only broke
* the line. Submit waits out the rest of this window first; a keystroke in that
* time still invalidates the receipt.
*/
export const SUBMIT_QUIET_MS = 250;
/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */
export interface StageSettleOptions {
timeoutMs?: number;
intervalMs?: number;
sleep?: (ms: number) => Promise<void>;
}
/**
* Every refusal after the keys went out says so: stage cannot tell whether the text
* landed, so the caller must look before staging again (the recovery steps clear a
* stranded draft with Ctrl+U).
*/
export const MAY_BE_TYPED =
'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again';
/**
* Conservatively read a composer: exactly one visible, unwrapped row and a known
* footer, both as the registry describes them. Never remove internal spaces, infer
* an unknown shell prompt, or interpret dialog options as authority. An unsupported
* frame yields no proof.
*/
export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null {
const isComposer = composerRow(chrome);
const lines = stripAnsi(screen)
.split('\n')
.map((line) => line.trimEnd());
const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line));
const last = rows.at(-1);
if (!last) return null;
const below = lines.slice(last.index + 1).filter((line) => line.trim());
if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null;
if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null;
// Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded.
const raw = screen.split('\n')[last.index];
let dim = false;
let visible = '';
// eslint-disable-next-line no-control-regex
for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) {
// eslint-disable-next-line no-control-regex
if (/^\x1b\[/.test(token)) {
const codes = token.slice(2, -1).split(';').map(Number);
for (let i = 0; i < codes.length; i++) {
// Extended color payloads may contain 0, 2 or 22; those are not SGR modes.
if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) {
i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0;
} else if (codes[i] === 0 || codes[i] === 22) dim = false;
else if (codes[i] === 2) dim = true;
}
} else if (!dim) visible += token;
}
// The glyph and the one space after it are the prefix, not input.
return stripAnsi(visible)
.trimStart()
.slice(chrome.promptGlyph.length + 1)
.trimEnd();
}
/**
* Does a suggestion popup sit directly above the composer row? A popup draws over
* the rows above the composer and leaves the footer below it alone, so the footer
* check alone reads a popup frame as an idle composer. Read the contiguous block of
* rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane:
* the transcript further up is the agent's own and may print anything. A transcript
* row that happens to match costs a refusal, never a receipt.
*/
function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean {
let index = composerIndex - 1;
for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--;
for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) {
if (popupLine.test(lines[index])) return true;
}
return false;
}
/** Require the capture's caret at the exact end of the unwrapped composer. */
export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null {
return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null;
}
/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
export function readGuardedSnapshotDetails(
snapshot: string,
chrome: GuardedComposerChrome
): { composer: string; prefixWidth: number } | null {
// formatPaneSnapshot emits one absolute row address per line, then the caret.
// Plain captures lack caret evidence and cannot prove invisible trailing edits.
// eslint-disable-next-line no-control-regex
const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)];
if (positions.length < 2) return null;
const caret = positions.at(-1)!;
if (caret.index! + caret[0].length !== snapshot.length) return null;
const rows: string[] = [];
for (let i = 0; i < positions.length - 1; i++) {
const position = positions[i];
if (Number(position[1]) !== i + 1 || position[2] !== '1') return null;
rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index));
}
const composer = readGuardedComposer(rows.join('\n'), chrome);
if (composer === null) return null;
const isComposer = composerRow(chrome);
let row = -1;
for (let i = rows.length - 1; i >= 0; i--)
if (isComposer.test(stripAnsi(rows[i]))) {
row = i;
break;
}
const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`));
if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1)
return null;
return { composer, prefixWidth: visibleWidth(prefix[1]) };
}
export class GuardedStageStore {
private readonly receipts = new Map<string, Receipt>();
private readonly busy = new Set<string>();
private readonly settleTimeoutMs: number;
private readonly settleIntervalMs: number;
private readonly sleep: (ms: number) => Promise<void>;
constructor(
private readonly now = Date.now,
private readonly capacity = 1024,
private readonly ttlMs = 120000,
settle: StageSettleOptions = {}
) {
this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS;
this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS;
this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
}
async stage(target: GuardedInputTarget, owner: string, message: string) {
// eslint-disable-next-line no-control-regex
const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message);
if (
!message ||
message !== message.trimEnd() ||
Buffer.byteLength(message) > STAGE_MAX_BYTES ||
Buffer.from(message).toString('utf8') !== message ||
hasControls
)
throw new GuardedInputConflict(
'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace'
);
const chrome = target.chrome();
const refusal = chrome && composerTriggerRefusal(message, chrome);
if (refusal) throw new GuardedInputConflict(refusal);
const key = target.id;
if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress');
this.busy.add(key);
try {
const before = target.snapshot();
if (!before || before.composerSha !== promptSha256(''))
throw new GuardedInputConflict('An empty, verifiable composer is required');
if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns)
throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane');
for (const [id, receipt] of this.receipts)
if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id);
// Refuse capacity before typing. Do not evict a caller's still-live receipt.
// Include in-flight stages (this operation already owns one busy slot),
// so different sessions cannot type concurrently into the same last slot.
if (this.receipts.size + this.busy.size > this.capacity)
throw new GuardedInputConflict('Guarded stage receipt capacity reached');
if (!(await target.write(message)))
throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`);
const typedAt = this.now();
const after = await this.settle(target, before, promptSha256(message));
const stageId = randomUUID();
const expiresAt = this.now() + this.ttlMs;
const sha256 = promptSha256(message);
this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt });
return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const };
} finally {
this.busy.delete(key);
}
}
/**
* The first frame after the write that shows exactly this message, with the input
* revision the write itself moved and nothing else changed. A null or stale frame
* (the old composer, a half-painted one) is read again until the settle window
* ends; a frame showing another writer's keystroke, a new pane lifecycle or a new
* geometry is refused at once, since waiting cannot undo that.
*/
private async settle(
target: GuardedInputTarget,
before: GuardedInputSnapshot,
expectedSha: string
): Promise<GuardedInputSnapshot> {
const deadline = this.now() + this.settleTimeoutMs;
for (;;) {
const frame = target.snapshot();
if (
frame &&
(frame.generation !== before.generation ||
frame.revision !== before.revision + 1 ||
frame.availableColumns !== before.availableColumns)
)
throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`);
if (frame?.composerSha === expectedSha) return frame;
if (this.now() >= deadline)
throw new GuardedInputConflict(
`The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}`
);
await this.sleep(this.settleIntervalMs);
}
}
async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) {
const receipt = this.receipts.get(stageId);
if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id)
throw new GuardedInputConflict('Unknown, used, or foreign stage receipt');
// One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter.
this.receipts.delete(stageId);
if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress');
this.busy.add(target.id);
try {
if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha)
throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch');
const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now();
if (quiet > 0) await this.sleep(quiet);
const current = target.snapshot();
if (
!current ||
current.generation !== receipt.generation ||
current.revision !== receipt.revision ||
current.availableColumns !== receipt.availableColumns ||
current.composerSha !== receipt.sha256
)
throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted');
if (!(await target.submit(current)))
throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane');
return { stageId, sessionId: target.id, submitted: true as const };
} finally {
this.busy.delete(target.id);
}
}
clear(): void {
this.receipts.clear();
this.busy.clear();
}
}
+226
View File
@@ -0,0 +1,226 @@
/**
* @fileoverview Renaming a case (its name and its folder) without losing what points at the folder.
*
* A case folder is held in more places than an open file. A running agent has it as its working
* directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history
* by the folder's path (`<config>/projects/<path with every non-alphanumeric as "-">`), so a moved
* folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an
* open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of
* that which are not route logic:
*
* - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
* folder while it is being moved, and two renames from overlapping;
* - the record of sessions constructed but not yet registered (`isCaseSessionStarting`), which a
* rename refuses to move a folder under, since it can neither list nor stop them;
* - the move itself, retried a few times for a transient lock and turned into a message that says
* what to do when it is a real one;
* - carrying Claude Code's history for the folder over to the new path.
*
* Pure of the server: every function takes the paths it works on.
*
* @module case-rename
*/
import fs from 'node:fs/promises';
import { dirname, join, resolve, sep } from 'node:path';
import { claudeProjectsDir } from './utils/claude-transcript.js';
export type CaseRenameErrorCode =
| 'INVALID' // a name that is not allowed, or the same name
| 'EXISTS' // the new name or folder is already taken
| 'BUSY' // another rename (or a session being stopped) holds the folder
| 'LOCKED' // the OS refused the move: something still has the folder open
| 'CROSS_DEVICE' // the move would cross filesystems
| 'UNSUPPORTED'; // remote and Docker cases
export class CaseRenameError extends Error {
constructor(
readonly code: CaseRenameErrorCode,
message: string
) {
super(message);
this.name = 'CaseRenameError';
}
}
const locked = new Set<string>();
const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep);
/** True when `path` is, or is inside, a folder that is being renamed right now. */
export function isCasePathLocked(path: string): boolean {
const target = resolve(path);
for (const held of locked) {
if (within(target, held)) return true;
}
return false;
}
/**
* Sessions constructed but not yet in the server's session map, where the rename route looks for the
* sessions it must list or stop. A create awaits a few things between `new Session` and that map (the
* owner's layout lock, a settings read), and a rename that ran in that gap neither saw nor stopped the
* session, moved the folder, and left it to start in a path that no longer existed.
*/
const starting = new Map<object, { path: string; until: number }>();
/** A session that never reaches the map (a create that failed, a one-shot prompt) stops counting after this. */
const STARTING_TTL_MS = 30000;
const dropExpiredStarts = (now: number): void => {
for (const [session, entry] of starting) {
if (entry.until <= now) starting.delete(session);
}
};
/** `session` (any object standing for it) is being created with `workingDir`; `Session`'s constructor calls this. */
export function noteCaseSessionStarting(session: object, workingDir: string, now = Date.now()): void {
dropExpiredStarts(now);
starting.set(session, { path: resolve(workingDir), until: now + STARTING_TTL_MS });
}
/** `session` is in the server's session map (or was stopped): the rename route sees it from here on. */
export function caseSessionSettled(session: object): void {
starting.delete(session);
}
/** True when a session is being created at or inside `path`, so a rename of it must wait. */
export function isCaseSessionStarting(path: string, now = Date.now()): boolean {
dropExpiredStarts(now);
const root = resolve(path);
for (const entry of starting.values()) {
if (within(entry.path, root)) return true;
}
return false;
}
/**
* Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside
* them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory
* inside a held folder, so nothing starts in a case that is half-moved.
*/
export async function withCaseLock<T>(from: string, to: string, fn: () => Promise<T>): Promise<T> {
const held = [resolve(from), resolve(to)];
for (const p of held) {
for (const other of locked) {
if (within(p, other) || within(other, p)) {
throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.');
}
}
}
for (const p of held) locked.add(p);
try {
return await fn();
} finally {
for (const p of held) locked.delete(p);
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
/**
* `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a
* handle another process is closing, an antivirus scan) before it is reported as locked.
* `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first.
*/
export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise<void> {
if (
await fs.lstat(to).then(
() => true,
() => false
)
) {
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
}
for (let i = 0; ; i++) {
try {
await fs.rename(from, to);
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (code === 'EXDEV') {
throw new CaseRenameError(
'CROSS_DEVICE',
'The new folder would be on a different drive or mount; it is not moved.'
);
}
if (LOCK_CODES.has(code)) {
if (i < attempts - 1) {
await sleep(delayMs * (i + 1));
continue;
}
throw new CaseRenameError(
'LOCKED',
`The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` +
'(an editor, a file browser or a sync client) and try again.'
);
}
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
}
throw err;
}
}
}
/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */
export function claudeProjectDirName(path: string): string {
return resolve(path).replace(/[^a-zA-Z0-9]/g, '-');
}
/**
* Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list
* still find the conversations. Returns whether anything moved.
*
* The new path can already have history: Claude Code never removes a project directory, and deleting a
* case does not either, so a case deleted earlier under the new name leaves one behind. The old history is
* then merged into it entry by entry (conversations are UUID-named, so they do not collide), never
* overwriting: an entry both directories have (a `memory/` folder, say) stays at the old path. A merge
* only goes between two real directories, never through a symlink.
*/
export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise<boolean> {
const projects = claudeProjectsDir(configDir);
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
if (from === to) return false;
const fromStat = await fs.lstat(from).catch(() => null);
if (!fromStat) return false;
const toStat = await fs.lstat(to).catch(() => null);
if (!toStat) {
try {
await fs.rename(from, to);
return true;
} catch {
return false; // history is a convenience; the folder move already succeeded
}
}
if (!fromStat.isDirectory() || !toStat.isDirectory()) return false;
let moved = false;
for (const entry of await fs.readdir(from).catch(() => [] as string[])) {
const target = join(to, entry);
if (
await fs.lstat(target).then(
() => true,
() => false
)
)
continue;
try {
await fs.rename(join(from, entry), target);
moved = true;
} catch {
/* left at the old path, like an entry both have */
}
}
await fs.rmdir(from).catch(() => {}); // succeeds only once nothing was left behind
return moved;
}
/** Replace `from` by `to` in a list, keeping its position (the case order). */
export function renameInOrder(order: unknown, from: string, to: string): string[] | null {
if (!Array.isArray(order) || !order.includes(from)) return null;
return order.map((n) => (n === from ? to : n)) as string[];
}
/** The folder a linked case moves to: its own parent, the new name. */
export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName);
+579 -47
View File
@@ -2,15 +2,21 @@
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
* inside a Codeman session, in every CLI mode.
*
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
* A thin HTTP client over the server's endpoints (`quick-start` for a named
* case, `POST sessions` + `sessions/:id/interactive|shell` for a worker in an
* existing directory, `input`, `wait`, `wait-output`, `last-response`, `terminal`,
* `DELETE sessions/:id`, the agent inbox: `inbox`, `inbox/ack`,
* `agent-inbox/summary`, and the experimental, opt-in
* `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403
* until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no
* transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership
* and the per-session waiter cap apply unchanged. The behaviour is
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
* preamble — has the same verbs from one line in its AGENTS.md.
*
* Invariants (each asserted in `test/cli-agent.test.ts`):
* Invariants (each asserted in `test/cli-agent.test.ts` or
* `test/cli-agent-project-spawn.test.ts`):
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
* never guesses a URL — a server you are not part of is not yours to drive.
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
@@ -22,14 +28,26 @@
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
* verb. `9` would resolve to whichever session is alone with that first
* character, the user's own interactive tab included.
* 5. `spawn` without a case starts the worker in an existing directory (cwd, or
* `--working-dir`) via `POST sessions` + `interactive|shell`, without the
* `X-Codeman-Agent-Origin` header, so no agent-case marker is written. It refuses
* (exit 4, before any request) an empty `--working-dir` and a bare spawn from a
* HOME cwd: it always sends an explicit `workingDir`, so the server's skip of
* workspace hooks for a request that names none cannot protect `~/.claude`.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
*
* @module cli-agent
*/
import { open, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { isUtf8 } from 'node:buffer';
import { STAGE_MAX_BYTES } from './agent-staging.js';
import { INSPECT_MAX_BYTES } from './agent-inspection.js';
import type { Command } from 'commander';
import {
basicAuthHeader,
@@ -190,6 +208,22 @@ export function stripAnsi(text: string): string {
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/**
* Inbox message text for a terminal. Another session wrote it, so an escape sequence
* in it would otherwise reach the reader's terminal (retitle it, or move the cursor
* over the `from` line above it). ANSI sequences go as in `read --tail`, and every
* control character left (C0 other than newline and tab, DEL, C1) is shown as `\xHH`,
* so a bare `\r` or backspace cannot overwrite the header either. A CRLF line end
* stays a line end. `--json` prints the text as stored.
*/
export function terminalSafeText(text: string): string {
return (
stripAnsi(text.replace(/\r\n/g, '\n'))
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
);
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
if (raw === undefined) return fallback;
@@ -326,6 +360,18 @@ export const httpRequest: ApiRequest = (ctx, options) => {
});
};
/**
* One line describing a thrown request error. Node 22 rejects a refused connection to a host
* that resolves to both ::1 and 127.0.0.1 with an AggregateError whose `message` is empty, so
* fall back to its `code` (ECONNREFUSED) before a generic line rather than printing nothing.
*/
export function describeRequestError(error: unknown): string {
const message = getErrorMessage(error);
if (message) return message;
const code = (error as { code?: unknown } | null | undefined)?.code;
return code ? String(code) : 'request failed without a message';
}
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
export function describeFailure(res: ApiResponse): string {
if (res.json && !res.json.success) {
@@ -355,7 +401,13 @@ export interface AgentDeps {
now?: () => number;
}
/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
/**
* Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy,
* except a directory spawn, which prints `{ sessionId, workingDir, created: true }` plus
* `ready` when a readiness wait ran, `readinessError` when that call failed, and
* `startupError` when its start fails, because `POST /api/sessions` answers with the
* whole session row.
*/
function emitJson(deps: AgentDeps, data: unknown): void {
deps.io.out(JSON.stringify(data, null, 2));
}
@@ -416,15 +468,55 @@ export async function resolveSessionId(
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
/** One session's row in `GET /api/agent-inbox/summary`. */
export interface InboxSummaryRow {
pending: number;
unseen: number;
oldestUnseenAt: number | null;
parked: boolean;
}
/**
* The inbox summary, or undefined when it cannot be had (a server without the route,
* a transient error). `ls` must still list sessions then, so this never fails the verb.
*/
async function fetchInboxSummary(deps: AgentDeps): Promise<Record<string, InboxSummaryRow> | undefined> {
try {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/agent-inbox/summary' });
if (!res.json?.success) return undefined;
return (res.json.data as { sessions?: Record<string, InboxSummaryRow> } | undefined)?.sessions ?? {};
} catch {
return undefined;
}
}
/** The INBOX cell of `ls`: `3 (1 new)`, `parked`, both, or empty. */
export function inboxCell(row: InboxSummaryRow | undefined): string {
if (!row) return '';
const parts: string[] = [];
if (row.pending > 0) parts.push(row.unseen > 0 ? `${row.pending} (${row.unseen} new)` : String(row.pending));
if (row.parked) parts.push('parked');
return parts.join(', ');
}
/** `agent ls` — every session the caller can see, self marked, with its inbox counts. */
export async function agentLs(deps: AgentDeps): Promise<number> {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
const [res, inbox] = await Promise.all([
deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }),
fetchInboxSummary(deps),
]);
if (!res.json?.success) return fail(deps, describeFailure(res));
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
if (deps.json) {
emitJson(
deps,
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
sessions.map((s) => ({
...s,
self: isSelfSession(deps.ctx.selfId, s.id),
// null = empty inbox and not parked. The key is absent when the server did
// not answer the summary, so "no data" never reads as "no mail".
...(inbox ? { inbox: inbox[s.id] ?? null } : {}),
}))
);
return EXIT.ok;
}
@@ -432,22 +524,28 @@ export async function agentLs(deps: AgentDeps): Promise<number> {
deps.io.out(palette.muted('(no sessions)'));
return EXIT.ok;
}
const rows = sessions.map((s) => [
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
s.id.slice(0, 8),
s.mode ?? '?',
s.status ?? '?',
s.name || s.workingDir || '',
]);
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
const rows = sessions.map((s) => {
const row = [isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ', s.id.slice(0, 8), s.mode ?? '?', s.status ?? '?'];
if (inbox) row.push(inboxCell(inbox[s.id]));
row.push(s.name || s.workingDir || '');
return row;
});
const header = inbox ? [' ', 'ID', 'MODE', 'STATUS', 'INBOX', 'NAME'] : [' ', 'ID', 'MODE', 'STATUS', 'NAME'];
deps.io.out(table([header, ...rows], { gap: 2 }));
deps.io.out(
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
);
if (inbox) {
deps.io.out(
palette.muted('INBOX: pending (unseen), parked = the session is waiting on `agent inbox --wait` right now.')
);
}
return EXIT.ok;
}
export interface SpawnOptions {
caseName: string;
caseName?: string;
workingDir?: string;
mode: string;
name?: string;
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
@@ -470,44 +568,135 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl
export function composerReadyMark(mode: string): string | undefined {
return getCli(mode)?.capabilities.composerReadyMark;
}
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
/** realpath, or the plain resolved path when it cannot be resolved; never throws. */
async function canonicalPath(path: string): Promise<string> {
try {
return await realpath(path);
} catch {
return resolve(path);
}
}
/**
* Whether `directory` is the user's home directory, comparing realpaths so a symlinked
* HOME (or a cwd reached through a symlink) still matches. False when HOME is unknown.
*/
export async function isHomeDirectory(directory: string): Promise<boolean> {
let home: string;
try {
home = homedir();
} catch {
return false;
}
if (!home) return false;
const [a, b] = await Promise.all([canonicalPath(directory), canonicalPath(home)]);
return a === b;
}
/** `agent spawn`: start in a project directory or named case, preserving lineage and mode readiness. */
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> {
const cli = getCli(options.mode);
if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused);
if (options.caseName !== undefined && options.workingDir !== undefined)
return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused);
// Same rule for the flag: `--working-dir "$WT"` with WT empty (a failed
// `git worktree add`) must not fall through to the caller's own checkout, which
// `resolve('')` would make it.
if (options.workingDir !== undefined && options.workingDir.trim() === '')
return fail(deps, 'refusing: --working-dir is empty; pass an existing directory or omit the flag', EXIT.refused);
// Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with
// CASE unset) still goes to quick-start, whose schema refuses it.
const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd());
if (workingDir) {
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(workingDir))
return fail(deps, 'Working directory contains control characters', EXIT.refused);
try {
if (!(await stat(workingDir)).isDirectory()) throw new Error('not a directory');
} catch {
return fail(deps, 'Working directory must be an existing local directory', EXIT.refused);
}
// The server installs workspace hooks (and, with agent skill injection on, the
// skill) into the directory it is given, and it skips that only when the caller
// names no workingDir, because the cwd fallback is $HOME under installer-created
// services. This CLI always sends its directory explicitly, so an implicit cwd of
// HOME must be refused here or it would write ~/.claude/settings.local.json and
// ~/.claude/skills. An explicit `--working-dir ~` is the caller's choice and stays.
if (options.workingDir === undefined && (await isHomeDirectory(workingDir)))
return fail(
deps,
'refusing: the current directory is your home directory; pass --working-dir ~ to start a worker there on purpose',
EXIT.refused
);
}
const body: Record<string, unknown> = {
caseName: options.caseName,
...(workingDir ? { workingDir } : { caseName: options.caseName }),
mode: options.mode,
parentSessionId: deps.ctx.selfId,
};
if (options.name) body.sessionName = options.name;
if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name;
const res = await deps.request(deps.ctx, {
method: 'POST',
path: '/api/v1/quick-start',
path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start',
body,
headers: { ...AGENT_ORIGIN_HEADER },
headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER },
});
const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
const created = res.json?.data as
| { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string }
| undefined;
// `POST /api/sessions` answers `{ session: { id, ... } }`; quick-start answers `{ sessionId, ... }`.
const createdId = created?.session?.id;
const data:
| { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean }
| undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created;
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
const sid = data.sessionId;
if (workingDir) {
// Creation alone leaves a session without a pane. Report the exact created id
// on an ambiguous or rejected start; never retry a start or hide a leaked tab.
let startupError: string | undefined;
try {
const started = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(sid)}/${cli.capabilities.startMode}`,
});
if (!started.json?.success) startupError = describeFailure(started);
} catch (error) {
startupError = getErrorMessage(error) || 'Startup request failed';
}
if (startupError) {
if (deps.json) emitJson(deps, { ...data, ready: false, startupError });
else deps.io.out(sid);
deps.io.err(`Session ${sid} was created but startup failed or is ambiguous: ${startupError}`);
return EXIT.error;
}
}
let ready: boolean | undefined;
let readinessError: string | undefined;
let dead = false;
const mark = composerReadyMark(options.mode);
if (options.ready && mark) {
const wait = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
// composer never showed up": report the real reason instead of the trust-dialog hint.
if (!wait.json?.success) readinessError = describeFailure(wait);
else {
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
// A worker that died while we waited is exit 3 like every other wait, not a
// "composer not seen" timeout that sends the caller looking for a dialog.
dead = waitExitCode(result) === EXIT.dead;
ready = !dead && Boolean(result?.matched);
try {
const wait = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
// composer never showed up": report the real reason instead of the trust-dialog hint.
if (!wait.json?.success) readinessError = describeFailure(wait);
else {
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
// A worker that died while we waited is exit 3 like every other wait, not a
// "composer not seen" timeout that sends the caller looking for a dialog.
dead = waitExitCode(result) === EXIT.dead;
ready = !dead && Boolean(result?.matched);
}
} catch (error) {
readinessError = describeRequestError(error);
}
}
@@ -516,7 +705,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis
} else {
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
const say = (line: string) => deps.io.err(line);
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, ${workingDir ?? data.caseName ?? options.caseName})`));
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
if (ready === false && !dead) {
@@ -695,6 +884,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<
return waitExitCode(data?.wait);
}
/** Bounded incremental evidence, with an opaque server-owned cursor. */
export async function agentInspect(
deps: AgentDeps,
options: { id: string; maxBytes: number; since?: string }
): Promise<number> {
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES)
return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const response = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`,
query: { maxBytes: options.maxBytes, since: options.since },
});
if (!response.json?.success) return fail(deps, describeFailure(response));
if (deps.json) emitJson(deps, response.json.data);
else {
const data = response.json.data as {
source?: string;
evidence?: string;
truncated?: boolean;
cursor?: string;
blockedReason?: string;
};
deps.io.out(
`[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}`
);
deps.io.out(data.evidence ?? '');
if (data.cursor) deps.io.out(`cursor: ${data.cursor}`);
}
return EXIT.ok;
}
/** Stage exactly the printable UTF8 file contents; never sends Enter. */
export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
let message: string;
try {
const info = await stat(options.messageFile);
if (!info.isFile() || info.size > STAGE_MAX_BYTES)
return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused);
const handle = await open(options.messageFile, 'r');
try {
const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const bytes = buffer.subarray(0, bytesRead);
if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes))
return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused);
message = bytes.toString('utf8');
} finally {
await handle.close();
}
} catch (error) {
return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused);
}
const refusal = inputRefusal(message);
if (refusal || message !== message.trimEnd())
return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const response = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`,
body: { message },
});
if (!response.json?.success) return fail(deps, describeFailure(response));
if (deps.json) emitJson(deps, response.json.data);
else {
const data = response.json.data as { stageId: string; sha256: string; expiresAt: number };
deps.io.out(
`staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}`
);
}
return EXIT.ok;
}
/** Submit one verified staged composer; failure never automatically replays Enter. */
export async function agentSubmit(
deps: AgentDeps,
options: { id: string; stageId: string; confirmSha: string }
): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId))
return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const response = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`,
body: { stageId: options.stageId, confirmSha: options.confirmSha },
});
if (!response.json?.success) return fail(deps, describeFailure(response));
if (deps.json) emitJson(deps, response.json.data);
else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).');
return EXIT.ok;
}
export interface ReadOptions {
id: string;
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
@@ -790,6 +1076,158 @@ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Agent inbox
// ─────────────────────────────────────────────────────────────────────────────
/** A message as `GET …/inbox` hands it out. */
export interface InboxMessageRow {
id: string;
from: string | null;
text: string;
createdAt: number;
seen: boolean;
}
/**
* The message `post` was given, which must be ONE argument, as with `send`: several
* would be an unquoted `$(cat notes.md)` split by the shell, and joining the pieces
* with spaces loses its newlines. Unlike `send`, newlines inside the one argument are
* fine: the inbox stores text as data and never types it.
*/
export function postTextFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return { error: `refusing: the message must be ONE argument, got ${words.length} — quote it` };
}
/** `agent post` — store a message in another session's inbox. Never types into its pane. */
export async function agentPost(deps: AgentDeps, options: { id: string; text: string }): Promise<number> {
if (options.text.length === 0) return fail(deps, 'refusing: empty message', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
body: { text: options.text },
});
// 409 = the receiver's inbox is full; nothing was stored. Passed through as an error.
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { message?: InboxMessageRow; pending?: number; unseen?: number } | undefined;
if (deps.json) emitJson(deps, data ?? {});
else
deps.io.out(
palette.ok(
`${GLYPH.ok} posted to ${target.id.slice(0, 8)} (${data?.pending ?? '?'} pending, ${data?.unseen ?? '?'} unseen)`
)
);
return EXIT.ok;
}
export interface InboxReadOptions {
/** Another session's inbox (a peek); omitted = this session's own. */
id?: string;
/** Long-poll budget in ms; the read parks while nothing is unseen. */
waitMs?: number;
}
/** True only when the session list was read and does not contain `id`. */
async function sessionIsGone(deps: AgentDeps, id: string): Promise<boolean> {
try {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return false;
return !((res.json.data as SessionRow[] | undefined) ?? []).some((s) => s.id === id);
} catch {
return false;
}
}
/** Relative age for humans: `12s`, `4m`, `3h`. */
function age(createdAt: number, now: number): string {
const s = Math.max(0, Math.round((now - createdAt) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
return `${Math.round(s / 3600)}h`;
}
/**
* `agent inbox` — read this session's inbox (or peek at another's). Removes nothing:
* a message stays until `agent ack <id>`. Only reading your OWN inbox marks messages
* seen. Exit 0 with mail, 2 when a `--wait` ran out with nothing unseen or the server
* cut it short (restart, detach), 3 when the session is gone from the list.
*/
export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id ?? deps.ctx.selfId);
if ('error' in target) return fail(deps, target.error, target.code);
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
query: { wait: options.waitMs },
timeoutMs: options.waitMs !== undefined ? options.waitMs + 10_000 : undefined,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| {
receiver?: boolean;
messages?: InboxMessageRow[];
pending?: number;
unseen?: number;
wait?: { timedOut?: boolean; ended?: boolean };
}
| undefined;
const messages = data?.messages ?? [];
// `ended` is also what a server restart or a detach answers. Exit 3 tells a receiver
// loop to stop, so it needs the session list to confirm the session is gone; while
// it is still listed (or the list cannot be had, as during a restart) the wait was
// only cut short, which is exit 2: loop again.
const gone = data?.wait?.ended ? await sessionIsGone(deps, target.id) : false;
const code = gone ? EXIT.dead : data?.wait?.timedOut || data?.wait?.ended ? EXIT.timeout : EXIT.ok;
if (deps.json) {
emitJson(deps, data ?? {});
return code;
}
const now = (deps.now ?? Date.now)();
for (const m of messages) {
const sender = m.from ? m.from.slice(0, 8) : 'user';
const tag = m.seen ? palette.muted('seen') : palette.emph('new');
deps.io.out(palette.muted(`── ${m.id} from ${sender}, ${age(m.createdAt, now)} ago, `) + tag);
deps.io.out(terminalSafeText(m.text));
}
if (gone) {
deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`));
} else if (data?.wait?.ended) {
deps.io.err(palette.muted('(the server ended the wait early, e.g. a restart; exit 2, wait again)'));
} else if (messages.length === 0) {
deps.io.err(palette.muted(data?.wait?.timedOut ? '(no mail; wait timed out, exit 2)' : '(no mail)'));
} else if (data?.receiver) {
deps.io.err(
palette.muted(
`${messages.length} pending. Nothing is removed by reading: when done, \`codeman agent ack <id>…\` (full ids above).`
)
);
} else {
deps.io.err(palette.muted(`${messages.length} pending (peek: nothing was marked seen).`));
}
return code;
}
/** `agent ack` — remove messages from THIS session's inbox by id. Idempotent. */
export async function agentAck(deps: AgentDeps, options: { ids: string[] }): Promise<number> {
const ids = options.ids.map((id) => id.trim()).filter(Boolean);
if (ids.length === 0) return fail(deps, 'refusing: no message ids', EXIT.refused);
const self = await resolveSessionId(deps, deps.ctx.selfId);
if ('error' in self) return fail(deps, self.error, self.code);
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(self.id)}/inbox/ack`,
body: { ids },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { removed?: number; pending?: number } | undefined;
if (deps.json) emitJson(deps, data ?? {});
else deps.io.out(palette.ok(`${GLYPH.ok} acked ${data?.removed ?? 0} (${data?.pending ?? '?'} still pending)`));
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commander wiring
// ─────────────────────────────────────────────────────────────────────────────
@@ -824,7 +1262,7 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P
try {
process.exitCode = await verb(deps);
} catch (err) {
console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
console.error(palette.err(`${GLYPH.fail} ${describeRequestError(err)}`));
process.exitCode = EXIT.error;
}
}
@@ -833,7 +1271,9 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P
export function registerAgentCommands(program: Command): Command {
const agent = program
.command('agent')
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
.description(
'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, the inbox: post, inbox, ack, and the experimental, opt-in inspect, stage, submit'
);
agent
.command('ls')
@@ -843,20 +1283,26 @@ export function registerAgentCommands(program: Command): Command {
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
agent
.command('spawn <case>')
.description(
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
)
.command('spawn [case]')
.description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported')
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
.option('-n, --name <name>', 'Session name shown in the UI')
.option(
'--working-dir <directory>',
'Existing project directory (defaults to cwd when no case is given; a cwd of HOME is refused, pass it explicitly)'
)
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
.option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
(
caseName: string | undefined,
options: { mode: string; name?: string; workingDir?: string; ready: boolean; timeout?: string; json?: boolean }
) =>
run(Boolean(options.json), (deps) =>
agentSpawn(deps, {
caseName,
workingDir: options.workingDir,
mode: options.mode,
name: options.name,
ready: options.ready,
@@ -953,6 +1399,49 @@ export function registerAgentCommands(program: Command): Command {
)
);
agent
.command('inspect <id>')
.description(
'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors'
)
.option('--max-bytes <n>', 'UTF8 evidence budget (1–65536)', '8192')
.option('--since <cursor>', 'Resume a cursor from the same owner, pane and transcript')
.option('--json', 'Machine-readable output')
.action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentInspect(deps, {
id,
maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'),
since: options.since,
})
)
);
agent
.command('stage <id>')
.description(
'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)'
)
.requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline')
.option('--json', 'Machine-readable output')
.action((id: string, options: { messageFile: string; json?: boolean }) =>
run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile }))
);
agent
.command('submit <id>')
.description(
'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match'
)
.requiredOption('--stage-id <receipt>', 'One-shot stage receipt')
.requiredOption('--confirm-sha <hash>', 'Exact SHA-256 returned by stage')
.option('--json', 'Machine-readable output')
.action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha })
)
);
agent
.command('read <id>')
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
@@ -977,6 +1466,49 @@ export function registerAgentCommands(program: Command): Command {
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
);
agent
.command('post <id> <text...>')
.description(
"Leave a message in another session's inbox (ONE quoted argument; multi-line is fine). Nothing is typed into its pane: it reads with `agent inbox`"
)
.option('--json', 'Machine-readable output')
// A message is often a file (`"$(cat task.md)"`) and markdown starts with `- ` or
// `---` often enough; without this commander takes it for an unknown option.
// `--json` and `-h` are still options: text that is exactly one goes after `--`.
.allowUnknownOption()
.action((id: string, words: string[], options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => {
const message = postTextFromArgs(words);
if ('error' in message) return Promise.resolve(fail(deps, message.error, EXIT.refused));
return agentPost(deps, { id, text: message.text });
})
);
agent
.command('inbox [id]')
.description(
"Read this session's inbox (or peek at another's with [id]). Reading removes nothing; `agent ack` does. --wait blocks while nothing is new: exit 2 on timeout"
)
.option('-w, --wait', 'Block until a new (unseen) message arrives')
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action((id: string | undefined, options: { wait?: boolean; timeout?: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentInboxRead(deps, {
id,
waitMs: options.wait ? parsePositiveInt(options.timeout, DEFAULT_WAIT_MS) : undefined,
})
)
);
agent
.command('ack <ids...>')
.description("Remove messages from this session's inbox by id, once handled (unknown ids are not an error)")
.option('--json', 'Machine-readable output')
.action((ids: string[], options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => agentAck(deps, { ids }))
);
agent
.command('rm <id>')
.description('Delete any session except this one (refuses your own id)')
+109
View File
@@ -0,0 +1,109 @@
/**
* @fileoverview `codeman history read`: the command-line front of the offline
* Codex history reader (`local-history.ts`).
*
* Deliberately separate from `codeman agent` (`cli-agent.ts`): that command is
* a client of a running Codeman and refuses outside a managed session, while
* this one reads rollout files from disk and needs neither a server nor
* `CODEMAN_MUX`. It never writes, never inspects a live pane and never sends
* input.
*
* Every bound the reader enforces has a flag here with the reader's own
* maximum, so a large store is a matter of raising a number rather than an
* impossible lookup. Output is JSON unless `--format text` is given; `--json`
* is the JSON spelling and refuses to combine with `--format text`.
*
* @module cli-history
*/
import type { Command } from 'commander';
import {
formatLocalHistory,
HISTORY_DEFAULT_MAX_ENTRIES,
HISTORY_DEFAULT_METADATA_BYTES,
HISTORY_DEFAULT_OUTPUT_BYTES,
HISTORY_DEFAULT_SCAN_BYTES,
HISTORY_MAX_ENTRIES,
HISTORY_MAX_METADATA_BYTES,
HISTORY_MAX_OUTPUT_BYTES,
HISTORY_MAX_SCAN_BYTES,
readLocalHistory,
validateOutputBytes,
} from './local-history.js';
/**
* A budget flag's value as a number. Only plain decimal digits count, so `0x10`,
* `1e3` and ` 5 ` are refused rather than read as 16, 1000 and 5; anything else
* becomes NaN, which the reader's range check rejects with the flag's name.
*/
function budgetFlag(value: string | boolean | undefined): number {
return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
}
export function registerHistoryCommands(program: Command): Command {
const history = program
.command('history')
.description('Read local saved native transcripts without a running server');
history
.command('read')
.description('Read Codex history by file, native id, or root conversation and descendants')
.option('--file <path>', 'Explicit local JSONL transcript (regular file only)')
.option('--session-id <id>', 'Native Codex conversation UUID or unique prefix (at least 8 characters)')
.option('--root-session-id <uuid>', 'Native root UUID, including transitive child conversations')
.option(
'--sessions-root <path>',
'Local Codex sessions directory (default: $CODEX_HOME/sessions, else ~/.codex/sessions)'
)
.option('--topic <text>', 'Case-insensitive literal match, preserving whole matching turns')
.option('--after <date>', 'Inclusive YYYY-MM-DD lower bound, as a UTC date; preserves whole matching turns')
.option('--before <date>', 'Inclusive YYYY-MM-DD upper bound, as a UTC date; preserves whole matching turns')
.option(
'--max-bytes <n>',
`Maximum complete output bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`,
String(HISTORY_DEFAULT_OUTPUT_BYTES)
)
.option(
'--scan-bytes <n>',
`Read budget for the selected transcripts (1-${HISTORY_MAX_SCAN_BYTES})`,
String(HISTORY_DEFAULT_SCAN_BYTES)
)
.option(
'--metadata-bytes <n>',
`Read budget for attributing every rollout in the store (1-${HISTORY_MAX_METADATA_BYTES})`,
String(HISTORY_DEFAULT_METADATA_BYTES)
)
.option(
'--max-entries <n>',
`Directory entries visited in the store (1-${HISTORY_MAX_ENTRIES})`,
String(HISTORY_DEFAULT_MAX_ENTRIES)
)
.option('--format <format>', 'json or text', 'json')
.option('--json', 'JSON output (the default; conflicts with --format text)')
.action(async (options: Record<string, string | boolean | undefined>, command: Command) => {
try {
if (options.format !== 'json' && options.format !== 'text') throw new Error('--format must be json or text');
if (options.json && options.format === 'text' && command.getOptionValueSource('format') === 'cli')
throw new Error('--json conflicts with --format text; pass one of them');
const format = options.format;
// Every flag is checked before the first file is read: --max-bytes would otherwise
// only be judged after the whole store had been scanned.
const maxBytes = budgetFlag(options.maxBytes);
validateOutputBytes(maxBytes);
const result = await readLocalHistory({
file: options.file as string | undefined,
sessionId: options.sessionId as string | undefined,
rootSessionId: options.rootSessionId as string | undefined,
sessionsRoot: options.sessionsRoot as string | undefined,
topic: options.topic as string | undefined,
after: options.after as string | undefined,
before: options.before as string | undefined,
scanBytes: budgetFlag(options.scanBytes),
metadataBytes: budgetFlag(options.metadataBytes),
maxEntries: budgetFlag(options.maxEntries),
});
program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, maxBytes)}\n`);
} catch (error) {
program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' });
}
});
return history;
}
+19 -1
View File
@@ -29,6 +29,7 @@ import {
import type { AgentSkillDir } from './config/cli-registry/types.js';
import { getCli, listClis } from './config/cli-registry/registry.js';
import { registerAgentCommands } from './cli-agent.js';
import { registerHistoryCommands } from './cli-history.js';
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -324,6 +325,7 @@ skillCmd
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands(program);
registerHistoryCommands(program);
// ============ MCP server (any MCP client drives Codeman) ============
@@ -950,6 +952,10 @@ function addWebLaunchOptions(cmd: Command): Command {
'--allow-unauthenticated-network',
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
)
.option(
'--allow-unauthenticated-tunnel',
'Allow starting the Cloudflare tunnel without CODEMAN_PASSWORD (dangerous; publishes terminal control to a public URL; env: CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL)'
)
.option(
'--multiuser',
'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)'
@@ -964,6 +970,7 @@ function toWebLaunchOptions(options: {
baseUrl?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
allowUnauthenticatedTunnel?: boolean;
multiuser?: boolean;
}): WebLaunchOptions {
const port = parseInt(options.port, 10);
@@ -985,6 +992,7 @@ function toWebLaunchOptions(options: {
basePath,
titleHostname: options.titleHostname,
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
allowUnauthenticatedTunnel: !!options.allowUnauthenticatedTunnel,
multiuser: !!options.multiuser,
};
}
@@ -1072,6 +1080,7 @@ webCmd.action(async (options) => {
const https = launch.https;
const titleHostname = options.titleHostname;
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
const allowUnauthenticatedTunnel = launch.allowUnauthenticatedTunnel ?? false;
const basePath = launch.basePath ?? '';
// Single source of truth for subsystems that read it directly (e.g. renderers).
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
@@ -1086,7 +1095,16 @@ webCmd.action(async (options) => {
try {
// The server prints its own "running at" line (it also covers the daemon and
// service launch paths), so this one used to be a duplicate of it.
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
const server = await startWebServer(
port,
https,
false,
host,
titleHostname,
allowUnauthenticatedNetwork,
basePath,
allowUnauthenticatedTunnel
);
if (https) {
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
}
+128
View File
@@ -0,0 +1,128 @@
/**
* @fileoverview Shape helpers for Codex rollout JSONL lines, shared by the two
* readers of `~/.codex/sessions`: the Past Sessions scanner
* (`codex-transcript.ts`, previews only) and the offline history reader
* (`local-history.ts`, whole conversations).
*
* Both readers must agree on three facts, so they live here once:
*
* - where the store is (`$CODEX_HOME/sessions`, else `~/.codex/sessions`);
* - which line shapes carry a user or assistant message;
* - which `response_item` user rows are injected context rather than input.
*
* ## The message shapes
*
* Codex has written conversation text under three shapes:
*
* - `event_msg` / `user_message` and `agent_message`, written by every version
* seen so far. `agent_message` carries a `phase` (`commentary` or
* `final_answer`); `user_message` carries none.
* - `event_msg` / `item_completed` with an `item.type` of `UserMessage` or
* `AgentMessage`, written by newer versions (0.152.1 and later).
* - `response_item` rows with `role: 'user'` or `'assistant'`. These are what
* the model saw, so the user rows mix real input with injected context.
*
* ## The injected-context rule
*
* Codex injects context as `response_item` user rows that start with a Markdown
* heading (`# AGENTS.md instructions for ...`) or an XML-style tag
* (`<environment_context>`, `<recommended_plugins>`, `<subagent_notification>`,
* `<skill>`, `<turn_aborted>`, `<image ...>`, `<codex_internal_context ...>`).
* Measured on a store of 2,916 rollouts (codex 0.120 to 0.163), every
* `response_item` user row with either prefix and no event twin was injected;
* the real prompts that start with `#` all have a `user_message` or
* `UserMessage` event twin, which this rule never filters. A fixed list of tag
* names would leak every wrapper Codex adds later (`<recommended_plugins>`
* alone was 1,355 rows on that store), so the rule is the prefix.
*
* @module codex-rollout-format
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
/** Codex's home directory: `$CODEX_HOME`, else `~/.codex`, exactly as codex resolves it. */
export function codexHomeDir(): string {
return process.env.CODEX_HOME || join(homedir(), '.codex');
}
/** The rollout store under {@link codexHomeDir}. */
export function codexSessionsRoot(): string {
return join(codexHomeDir(), 'sessions');
}
/**
* Flatten Codex message content, which is a string or an array of blocks.
* Only blocks carrying a string `text` contribute (images and skills do not).
* Not trimmed: callers that want a preview trim.
*/
export function codexContentText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.filter(
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
)
.map((b) => b.text)
.filter(Boolean)
.join('\n');
}
/** Injected context rather than typed input. Applies to `response_item` user rows only. */
export function isCodexInjectedContext(text: string): boolean {
const start = text.trimStart();
return start.startsWith('#') || start.startsWith('<');
}
/** One rollout line's user or assistant message, whichever shape carried it. */
export interface CodexLineMessage {
role: 'user' | 'assistant';
/** Untrimmed text, never empty. */
text: string;
/** `event` = user_message/agent_message, `item` = item_completed, `response` = response_item. */
shape: 'event' | 'item' | 'response';
/** Codex's own phase (`commentary` / `final_answer`) when the line carries one. */
phase?: string;
/** A `response_item` user row: may be injected context, see {@link isCodexInjectedContext}. */
injectionProne: boolean;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function phaseOf(value: unknown): string | undefined {
return typeof value === 'string' && value ? value : undefined;
}
/** The message a parsed rollout line carries, or null for every other line. */
export function codexMessageFromLine(entry: unknown): CodexLineMessage | null {
const e = record(entry);
const p = record(e.payload);
let role: CodexLineMessage['role'] | undefined;
let text = '';
let shape: CodexLineMessage['shape'] = 'event';
let phase: string | undefined;
if (e.type === 'event_msg') {
if (p.type === 'user_message' || p.type === 'agent_message') {
role = p.type === 'user_message' ? 'user' : 'assistant';
text = codexContentText(p.message);
phase = phaseOf(p.phase);
} else if (p.type === 'item_completed') {
const item = record(p.item);
if (item.type === 'UserMessage' || item.type === 'AgentMessage') {
role = item.type === 'UserMessage' ? 'user' : 'assistant';
shape = 'item';
text = codexContentText(item.content);
phase = phaseOf(item.phase);
}
}
} else if (e.type === 'response_item' && (p.role === 'user' || p.role === 'assistant')) {
role = p.role;
shape = 'response';
text = codexContentText(p.content);
phase = phaseOf(p.phase);
}
if (!role || !text) return null;
return { role, text, shape, phase, injectionProne: shape === 'response' && role === 'user' };
}
+16 -53
View File
@@ -37,13 +37,20 @@
* summaries), so they are read only when neither shape above appears, and
* the obvious injections are dropped.
*
* The shapes, the injected-context rule and the store location live in
* `codex-rollout-format.ts`, shared with the offline reader `local-history.ts`.
*
* @module codex-transcript
*/
import { open, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
codexMessageFromLine,
codexSessionsRoot,
isCodexInjectedContext as isInjectedContext,
} from './codex-rollout-format.js';
import { LRUMap } from './utils/lru-map.js';
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
@@ -134,11 +141,6 @@ function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boole
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
}
function codexSessionsRoot(): string {
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
return join(home, 'sessions');
}
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
async function readHead(path: string, bytes: number): Promise<string> {
const fh = await open(path, 'r').catch(() => null);
@@ -176,55 +178,16 @@ async function readTail(path: string, size: number, bytes: number): Promise<stri
}
}
/** Flatten codex's message content, which is a string or an array of text blocks. */
function contentText(content: unknown): string {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
return content
.filter(
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
)
.map((b) => b.text)
.join('\n')
.trim();
}
/** One line's user-prompt text, whichever of the three shapes it is. */
function userPromptFromLine(entry: {
type?: string;
payload?: {
type?: string;
role?: string;
content?: unknown;
message?: unknown;
item?: { type?: string; content?: unknown };
};
}): { text: string; injectionProne: boolean } | null {
const p = entry.payload;
if (!p) return null;
if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
const text = contentText(p.item.content);
return text ? { text, injectionProne: false } : null;
}
if (entry.type === 'event_msg' && p.type === 'user_message') {
const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
return text ? { text, injectionProne: false } : null;
}
if (entry.type === 'response_item' && p.role === 'user') {
const text = contentText(p.content);
return text ? { text, injectionProne: true } : null;
}
return null;
}
/**
* Injected context rather than something the user typed. Codex prepends the
* repository's AGENTS.md and wraps environment context in a tag, and both arrive
* as `response_item` user rows.
* One line's user-prompt text, whichever of the three shapes it is. The shapes
* and the injected-context rule are shared with the offline history reader
* (`codex-rollout-format.ts`), so the two never disagree about what a prompt is.
*/
function isInjectedContext(text: string): boolean {
return text.startsWith('#') || text.startsWith('<');
function userPromptFromLine(entry: unknown): { text: string; injectionProne: boolean } | null {
const message = codexMessageFromLine(entry);
if (!message || message.role !== 'user') return null;
const text = message.text.trim();
return text ? { text, injectionProne: message.injectionProne } : null;
}
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
+17 -1
View File
@@ -21,9 +21,25 @@ export const MAX_AUTH_SESSIONS = 100;
// Rate Limiting
// ============================================================================
/** Max failed auth attempts per IP before 429 rejection */
/**
* Max failed auth attempts per bucket before 429 rejection. Applies to the
* single-user per-IP bucket, the hook-secret bucket and two multi-user buckets:
* the (username, IP) gate, checked before the scrypt verify, and the
* per-username bucket, checked after it.
*/
export const AUTH_FAILURE_MAX = 10;
/**
* Max failed login attempts per client IP for the multi-user pre-gate, which
* runs before the scrypt verify and so refuses even a correct password once
* reached. It bounds scrypt cost from one source and is wider than
* AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can carry many users'
* failures. Verified guesses against any one account are capped by the
* (username, IP) gate at AUTH_FAILURE_MAX, not by this. Single-user mode does
* not use this.
*/
export const AUTH_IP_FAILURE_MAX = 50;
/** Failed auth attempt tracking window (ms) */
export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
+32
View File
@@ -301,6 +301,9 @@ const capabilitiesSchema = z
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
// Optional: absent means the keyboard resizes, the safe default.
keyboardHold: z.literal('inline').optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
@@ -384,6 +387,35 @@ const capabilitiesSchema = z
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
// Same guard again: run over the rows under the composer each time guarded
// input reads the pane, and ~/.codeman/clis.json can set it.
composerFooterLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the rows just above the composer by the same reads.
composerPopupLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Single ASCII punctuation characters, never letters, digits or whitespace: each one
// refuses every message that starts (or whose last word starts) with it.
composerCommandPrefixes: z
.string()
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters')
.optional(),
composerCompletionSigils: z
.string()
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters')
.optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
+41 -1
View File
@@ -13,6 +13,14 @@
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
/**
* Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what
* codex prints when no effort is configured), as one regex alternation. Shared by the two
* patterns that read that footer (`modelDetect.screenLine` and
* `workDetect.composerFooterLine`), so a new level is a one-place edit.
*/
const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|');
const HOME_DIRS = {
local: '~/.local/bin',
usrLocal: '/usr/local/bin',
@@ -289,6 +297,10 @@ const CLAUDE: CliEntry = {
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
// Claude's default renderer is inline, and each keyboard-driven PTY height change made
// it reprint only the tail that fits, erasing transcript rows from tmux (measured on
// 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold.
keyboardHold: 'inline',
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
@@ -734,6 +746,34 @@ const CODEX: CliEntry = {
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
// The rows codex draws under an idle composer, tested one at a time by guarded
// stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default ·
// ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the
// `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older
// `NN% context left` footer. Indented, so the `›` composer row itself never matches.
composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`,
// Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/
// popup_state.rs, composer_layout.rs and the popups' render code and snapshots):
// every suggestion popup renders ABOVE the composer and keeps the footer below it,
// so the footer alone cannot tell `› /stat` with the command list open from an
// idle draft, and Enter then runs the highlighted `/status`. What sits in the
// block just above the composer (past its blank top padding row):
// command list (`/`) `› /status show current session ...`
// ` /statusline configure which items ...`
// skill and mention lists ` enter insert · esc close` (key-hint row)
// (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter`
// file search (`@`) ` loading...` / ` no matches`, else bare path
// rows, which no pattern can tell from prose
// The last case is why stage also refuses the triggers themselves (below).
composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`,
// Same source. A draft starting with `/` is a slash command (the popup opens while
// the caret is in its name, and Enter runs a command either way), and one starting
// with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups
// open for the word under the caret only: `@` (file search, or the unified mention
// list) and `$` (skills and mentions; whether a given `$word` opens it depends on
// codex's skill catalog, so every `$` word is refused).
composerCommandPrefixes: '/!',
composerCompletionSigils: '@$',
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
@@ -757,7 +797,7 @@ const CODEX: CliEntry = {
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read.
modelDetect: {
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2,
},
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
+57
View File
@@ -432,6 +432,39 @@ export interface CliCapabilities {
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
/**
* Source of a regex every non-blank row BELOW an idle composer must match (at most
* three rows): the footer and hint rows this CLI draws there and nowhere else. It is
* what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove
* that the `promptGlyph` row they read is this CLI's own composer, at rest, and not
* a dialog option or a transcript line the agent printed. A CLI that omits it never
* takes guarded input: stage and submit refuse it. Tested one row at a time.
*/
composerFooterLine?: string;
/**
* Source of a regex matching a row of a suggestion popup this CLI draws directly
* ABOVE its composer (a slash-command list, a file or mention search, its key-hint
* row). The footer below such a composer is unchanged, so without this guarded
* stage/submit would read the popup frame as an idle composer, and Enter would
* accept the highlighted entry instead of sending the draft. Tested one row at a
* time against the contiguous block just above the composer row, never further
* up the pane: a match there refuses the frame. See `readGuardedComposer()`.
*/
composerPopupLine?: string;
/**
* Characters that, as the first character of a draft, make Enter run a command
* rather than send a prompt (codex: `/` slash commands, `!` shell commands).
* Guarded stage refuses such a message before typing; commands go through the
* ordinary input route.
*/
composerCommandPrefixes?: string;
/**
* Characters that, starting the word under the caret, open a completion popup
* whose highlighted entry Enter accepts (codex: `@` file and mention search, `$`
* skills). Guarded stage refuses a message whose LAST word starts with one,
* before typing: the caret ends there, and the popup follows the caret.
*/
composerCompletionSigils?: string;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
@@ -563,6 +596,30 @@ export interface CliCapabilities {
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
/**
* `'inline'`: the CLI renders inline (its content stacks at the bottom of the
* main screen), so while a phone's on-screen keyboard is up the browser keeps
* the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`,
* terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through
* `window.__codemanKeyboardHoldModes`, which the server builds from this field.
*
* Why it exists: every PTY height change makes Claude Code's inline renderer
* clear the screen and reprint only the tail that fits (#672). Measured on
* Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's
* size printing 100 numbered lines: five keyboard open/close cycles (PTY
* 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in
* `tmux capture-pane` history, the other 25 gone for good; with the rows held
* the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained.
*
* ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height
* (codex: banner at the top, composer pinned to the bottom) cannot fit both
* ends above the keyboard and must be resized instead, so it stays absent.
* Absent means resize, the safe default for an unmeasured CLI. Even when set,
* the hold stands aside while the CLI reports mouse tracking (Claude's
* fullscreen mode, which lays out the full height and has not been measured
* losing lines, so it resizes as before), and for remote and docker sessions.
*/
keyboardHold?: 'inline';
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
+11
View File
@@ -70,6 +70,17 @@ export const SESSION_LIMIT_WAIT_MS = 5000;
/** Pause between scheduled run iterations (ms) */
export const ITERATION_PAUSE_MS = 2000;
// ============================================================================
// Git Fetch Interval
// ============================================================================
/**
* How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which sessions are due (ms). A
* run lands within one tick of the interval the user set, because the interval is timed from the start of
* a run; a tick is a settings read and a due check, not a spawn, so a quiet install costs nothing.
*/
export const GIT_FETCH_TICK_INTERVAL_MS = 15 * 1000;
// ============================================================================
// Mux Stats
// ============================================================================
+2
View File
@@ -49,6 +49,7 @@ export interface WebLaunchOptions {
basePath?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
allowUnauthenticatedTunnel?: boolean;
multiuser?: boolean;
}
@@ -92,6 +93,7 @@ export function buildWebArgs(options: WebLaunchOptions): string[] {
if (options.basePath) args.push('--base-url', options.basePath);
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
if (options.allowUnauthenticatedTunnel) args.push('--allow-unauthenticated-tunnel');
if (options.multiuser) args.push('--multiuser');
return args;
}
+9 -7
View File
@@ -26,13 +26,15 @@
* **1. One zstd FRAME per append, not one zstd stream.** The file is
* `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into
* its own frame and writing it at the end. `zstd -dc` handles that (frames
* concatenate by definition), but Node's `zlib.zstdDecompress()` and
* `createZstdDecompress()` both stop at the first frame end: measured on a real
* 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned
* 43,747 bytes / 56 lines. That is a silent truncation to the session header —
* every call would have reported "no answer yet" forever. `decodeZstdFrames()`
* below walks the frame headers itself and decompresses each frame, and
* `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures.
* concatenate by definition), but before Node 24.22 and 26.10
* (nodejs/node#65865) `zlib.zstdDecompress()` and `createZstdDecompress()` both
* stop at the first frame end, and the supported range still includes those
* versions: measured on a real 56-line transcript, Node returned 158 bytes /
* 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent
* truncation to the session header: every call would have reported "no answer
* yet" forever. `decodeZstdFrames()` below walks the frame headers itself and
* decompresses each frame, and `test/deepseek-transcript.test.ts` pins it
* against multi-frame fixtures.
*
* **2. The user's prompts are mixed with injected context.** Every turn also
* writes a `user/message` whose source is a plugin (the runtime-context
+26
View File
@@ -130,6 +130,32 @@ export async function readDockerCases(configDir: string): Promise<DockerCase[]>
return readJsonArray<DockerCase>(dockerCasesPath(configDir));
}
/**
* The Docker cases for a caller that must fail CLOSED (the git fetch paths): a missing file is no cases,
* but a file that cannot be read, does not parse or is not an array rejects, where {@link readDockerCases}
* reads all of those as `[]`. The writer truncates and rewrites the file in place, so a read can land on an
* empty or half-written file, and to a caller deciding where git may run that must never look like "no
* Docker workspaces". The parser's own message is not repeated (V8 quotes the text around the fault).
*/
export async function readDockerCasesOrThrow(configDir: string): Promise<DockerCase[]> {
const path = dockerCasesPath(configDir);
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
if (!Array.isArray(parsed)) throw new Error(`${path} is not a valid JSON array`);
return parsed as DockerCase[];
}
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> {
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
}
+307
View File
@@ -0,0 +1,307 @@
/**
* @fileoverview Git fetch on an interval: the sessions the user turned on, fetched by a tick the server owns
* (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`).
*
* The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer
* it is the only one that can run without being asked (cloning and worktree management write too, but those
* happen when the user asks for them). Which is why the whole feature is off until the user turns it on, in
* Settings → Bottom bar, per session in Session Options → Git.
*
* The scheduler holds no timers of its own. The server ticks it (`GIT_FETCH_TICK_INTERVAL_MS`) and every
* tick reads the app setting FRESH, so a live toggle takes effect on the next tick instead of at boot, and
* turning the feature off actually stops the fetching. A session can override both the switch and the
* interval; a session with no override follows the app.
*
* Rules the code keeps and the tests pin:
* - Nothing runs when nothing is on: no repository set is read and nothing is spawned, so a quiet
* install costs one settings read per tick. A session that turned itself on still runs even while the
* app-wide switch is off: that is what a per-session switch has to mean.
* - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker
* workspace roots are checked again every tick, so a session whose case became a Docker case stops
* fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches
* nothing (a fetch runs a repository's hooks, and without the list nothing tells which folders a
* container can write to), the folders it picked stay due for the next tick, and it is logged once
* until a read succeeds again.
* - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one
* session: two sessions open on the same folder are the same clone, so they share one run and both get
* its result. State is keyed by `resolve(workingDir)`, deliberately not `realpath`, because `realpath`
* is itself one of the unbounded fs calls rule #516 bounds; the bounded probe below is the fs read.
* Every session open on a folder is remembered, so closing the one holding the override does not stop
* the folder's fetching while another session is still on it. A folder is due when ANY session open on it
* is due, and the tick decides that per folder in a second pass: the order the sessions were read cannot
* change which interval wins, so a 30 s session on a folder a 3600 s session last fetched ten minutes ago
* is fetched now. The folder is probed once, not once per session on it.
* - Before anything is fetched, the folder is probed BOUNDED with the default options (rule #516): a case
* on a mount that stopped answering is skipped, never touched, and a probe that answers `unknown` is not
* read as "not a folder".
* - A schedule is stamped at SELECTION, when a session goes into `due`, not when its run starts, so a
* tick that is still running cannot pick the same folder again; and a tick is skipped while the previous
* one is still going, so the bound below is the bound for the whole tick, not per tick.
* - At most `FETCH_CONCURRENCY` git processes at a time across the whole tick, because each is a git
* process: ONE bound is shared by every run of the tick, so `FETCH_CONCURRENCY` is the tick's ceiling
* rather than each folder's own. The folders get their OWN bound (`mapLimited`), because one semaphore
* cannot serve both levels: a folder that holds a slot and then waits for one for its own fetch is
* waiting on itself, and the folders that fill the bound are exactly the ones blocking it.
* - The interval is timed from the START of a run, so a run that takes three minutes does not push the
* next one out by three minutes; it lands on the tick after its own interval elapses.
* - The per-repository limit and the git timeout stay the module's defaults (`MAX_REPOS`,
* `DEFAULT_GIT_TIMEOUT_MS`): both settings are per device and never reach the server, so the server
* cannot know what a device chose.
* - A failure is reported, not retried harder: the folder keeps its schedule and gets its next run at its
* own interval. A settings read that fails skips this tick only, rather than deciding from defaults.
* settings.json is replaced atomically, so such a failure is a file that does not parse (a hand edit) or
* a real read error, which lasts until someone fixes it: it is logged once, and again only after a read
* has succeeded in between, not every 15 s tick.
*
* @module git-fetch-scheduler
*/
import { resolve } from 'node:path';
import { getErrorMessage } from './types.js';
import {
FETCH_CONCURRENCY,
MAX_GIT_FETCH_INTERVAL_SECONDS,
MIN_GIT_FETCH_INTERVAL_SECONDS,
gitFetchStatusFrom,
type GitFetchOptions,
type GitFetchRun,
type GitFetchSettings,
type GitFetchStatus,
type GitFetchTrigger,
} from './git-fetch.js';
import { clampInt, DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS, createLimiter, mapLimited } from './git-workspace-status.js';
import type { PathProbeKind } from './utils/bounded-path-probe.js';
/** What the scheduler needs from a session: the facts a decision needs, and nothing else. */
export interface GitFetchSessionView {
id: string;
workingDir: string;
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
remote?: unknown;
docker?: unknown;
gitFetchEnabled?: boolean;
gitFetchIntervalSeconds?: number;
}
/** A session open on a folder, with the interval that makes IT due. One folder's sessions are a group. */
interface GroupMember {
view: GitFetchSessionView;
intervalSeconds: number;
}
export interface GitFetchPorts {
sessionIds(): Iterable<string>;
getSession(id: string): GitFetchSessionView | undefined;
/** Read FRESH every tick; that is the whole point of the tick-driven shape. */
readSettings(): Promise<GitFetchSettings>;
/**
* Host paths of every Docker case workspace. REJECTS when the list exists but cannot be read or parsed; the
* tick then fetches nothing and the folders it picked are due again on the next tick.
*/
dockerWorkspaces(): Promise<string[]>;
/**
* Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called
* with `pastCap`. Anything that is not `directory` (including `unknown`, which is never `absent`) is
* skipped, not fetched.
*/
probePath(path: string): Promise<PathProbeKind>;
/** Called through `fetchWorkspaceOnce`, so a run already going for this folder is joined, not stacked. */
fetch(cwd: string, opts: GitFetchOptions, trigger: GitFetchTrigger): Promise<GitFetchRun>;
publish(id: string, status: GitFetchStatus): void;
log?(message: string): void;
}
/**
* What one session will actually do: its own override wins, otherwise the app-wide setting. The interval is
* clamped again here because a persisted value can predate the schema's bounds.
*/
export function effectiveGitFetch(
view: GitFetchSessionView,
settings: GitFetchSettings
): { enabled: boolean; intervalSeconds: number } {
return {
enabled: view.gitFetchEnabled ?? settings.enabled,
intervalSeconds: clampInt(
view.gitFetchIntervalSeconds ?? settings.intervalSeconds,
MIN_GIT_FETCH_INTERVAL_SECONDS,
MAX_GIT_FETCH_INTERVAL_SECONDS,
MIN_GIT_FETCH_INTERVAL_SECONDS
),
};
}
export class GitFetchScheduler {
private readonly ports: GitFetchPorts;
/** When each FOLDER last started a run; timed from the start, not the end. */
private readonly lastRunAt = new Map<string, number>();
private readonly inFlight = new Set<string>();
/** Session → folder, so `remove()` can keep the folder's schedule while other sessions are still on it. */
private readonly folderOf = new Map<string, string>();
private running = false;
private stopped = false;
/** The last settings read failed and was logged; cleared by the next read that succeeds. */
private settingsUnreadable = false;
/** The same for the Docker case list. */
private dockerCasesUnreadable = false;
constructor(ports: GitFetchPorts) {
this.ports = ports;
}
/** One tick: fetch every folder whose sessions are on and due. Returns how many folders ran. */
async tick(now = Date.now()): Promise<number> {
if (this.stopped || this.running) return 0;
this.running = true;
try {
let settings: GitFetchSettings;
try {
settings = await this.ports.readSettings();
} catch (err) {
// Writes are atomic, so this is a real read error or a file that does not parse (a hand edit), and it
// stays that way until it is fixed: logged once, not on every tick. The reader never puts the file's
// text in its error (readGitFetchSettings), since settings.json can hold an API key.
if (!this.settingsUnreadable) {
this.settingsUnreadable = true;
this.ports.log?.(
`git fetch: settings unreadable, ticks skipped until it reads again: ${getErrorMessage(err)}`
);
}
return 0;
}
this.settingsUnreadable = false;
// Pass 1: who is on what, before anything is decided. Every session open on a folder is recorded here,
// not only the one that would get picked, so `remove()` can still see who is open on it when the session
// holding the override is the one that closes.
const groups = new Map<string, GroupMember[]>();
for (const id of this.ports.sessionIds()) {
const view = this.ports.getSession(id);
if (!view || view.remote || view.docker) continue;
const effective = effectiveGitFetch(view, settings);
if (!effective.enabled) continue;
const folder = resolve(view.workingDir);
this.folderOf.set(id, folder);
const group = groups.get(folder);
const member = { view, intervalSeconds: effective.intervalSeconds };
if (group) group.push(member);
else groups.set(folder, [member]);
}
// Pass 2: the folder's turn, decided from the whole group rather than from whichever session happened to
// be read first. A folder is due when ANY session open on it is due: a session on a 30 s interval on a
// folder a 3600 s session last fetched ten minutes ago has to be fetched now, because that same run
// satisfies the long interval too. Deciding in one pass means the session read first owns the clock: a
// group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the
// run belongs to the folder, so no session in the group should.
const due = new Map<string, GroupMember[]>();
// What each picked folder's clock said before this tick stamped it, so a tick that ends up fetching
// nothing (an unreadable Docker case list, below) can leave the folders due again.
const stampedOver = new Map<string, number | undefined>();
for (const [folder, group] of groups) {
if (this.inFlight.has(folder)) continue;
const started = this.lastRunAt.get(folder);
const isDue = started === undefined || group.some((m) => now - started >= m.intervalSeconds * 1000);
if (!isDue) continue;
// Bounded probe (rule #516), once per folder rather than once per session on it.
const kind = await this.ports.probePath(folder);
if (kind !== 'directory') {
this.ports.log?.(`git fetch: ${folder} skipped, answered ${kind}`);
continue;
}
this.inFlight.add(folder);
// The clock starts at the selection, so a slow folder does not shift its own schedule.
stampedOver.set(folder, started);
this.lastRunAt.set(folder, now);
due.set(folder, group);
}
if (!due.size) return 0;
// Only now, because nothing above needs it: a quiet tick does not read the Docker case list.
let dockerWorkspaces: string[];
try {
dockerWorkspaces = await this.ports.dockerWorkspaces();
} catch (err) {
// Fail CLOSED: a fetch runs the repository's hooks, and without the list nothing tells which folders a
// container can write to. Nothing ran, so the picks are undone (a stamp a `remove()` already cleared
// stays cleared) and the folders are due again on the next tick instead of after their interval.
for (const [folder, started] of stampedOver) {
this.inFlight.delete(folder);
if (this.lastRunAt.get(folder) !== now) continue;
if (started === undefined) this.lastRunAt.delete(folder);
else this.lastRunAt.set(folder, started);
}
if (!this.dockerCasesUnreadable) {
this.dockerCasesUnreadable = true;
this.ports.log?.(
`git fetch: Docker case list unreadable, nothing fetched until it reads again: ${getErrorMessage(err)}`
);
}
return 0;
}
this.dockerCasesUnreadable = false;
// ONE bound for the git processes of the whole tick, handed to every run so no folder gets its own:
// each fetch is one git process, and a folder can hold up to `MAX_REPOS_LIMIT` of them, so a bound per
// run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once.
const fetches = createLimiter(FETCH_CONCURRENCY);
// The folders get their OWN bound, because the same semaphore cannot serve both levels: a folder that
// holds a slot and then waits for a slot for its own fetch is waiting on itself: once
// `FETCH_CONCURRENCY` folders fill the bound, the ones holding slots are exactly the ones that can
// never get one back. Both bounds are `FETCH_CONCURRENCY`, so the tick still never runs more git
// processes than the module's ceiling.
await mapLimited([...due.entries()], FETCH_CONCURRENCY, async ([folder, group]) => {
try {
const run = await this.ports.fetch(
folder,
{
dockerWorkspaces,
maxRepos: MAX_REPOS,
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
limiter: fetches,
},
'interval'
);
// Every session on the folder gets the SAME run, with the trigger of the run that actually happened:
// if this tick joined a run a click had already started, the row has to say it was a click, because
// that is what fetched it.
for (const member of group) this.ports.publish(member.view.id, gitFetchStatusFrom(run.report, run.trigger));
} catch (err) {
// The run that failed keeps its reason in the panel's own row; here only the folder-level failure is
// worth a line, because nothing else would say a scheduled fetch could not run at all.
this.ports.log?.(`git fetch: ${folder} could not run: ${getErrorMessage(err)}`);
} finally {
this.inFlight.delete(folder);
}
});
return due.size;
} finally {
this.running = false;
}
}
/**
* A session that closed or was detached: its schedule goes with it, but the FOLDER's stays while another
* session is still open on it: the run belongs to the clone, not to the session that asked for it.
*/
remove(id: string): void {
const folder = this.folderOf.get(id);
this.folderOf.delete(id);
if (!folder) return;
const stillOpen = [...this.folderOf.values()].includes(folder);
if (!stillOpen) {
this.lastRunAt.delete(folder);
this.inFlight.delete(folder);
}
}
stop(): void {
this.stopped = true;
this.lastRunAt.clear();
this.inFlight.clear();
this.folderOf.clear();
}
/** Folders the scheduler still remembers a run for, so a test can see a `remove()` actually took. */
tracked(): number {
return new Set([...this.lastRunAt.keys(), ...this.inFlight]).size;
}
}
+506
View File
@@ -0,0 +1,506 @@
/**
* @fileoverview `git fetch` for the repositories the Git panel shows, on the user's click
* (`POST /api/sessions/:id/git-fetch`) or on an interval the user turned on
* (`src/git-fetch-scheduler.ts`).
*
* The status module (src/git-workspace-status.ts) is deliberately offline: "behind" is as of the last
* fetch, so a number that is stale can only be made current by fetching. This is the only git command the
* Git panel runs that writes, and the only git command Codeman runs on a timer. Cloning a repository and
* managing worktrees write too, but those are things the user asked for at the moment they asked; this is
* the one that can happen later, by itself. Nothing here commits, checks out, merges or touches the working
* tree: `git fetch` updates remote-tracking
* refs and nothing else, and `FETCH_CONFIG_ARGS` keeps it from starting a gc in a repository an agent is
* already using. It is not harmless in the way a read is: a fetch holds git's ref lock, so an agent's own
* `git pull` in the same clone can collide with it (`cannot lock ref`), which is why one folder is fetched
* by at most one run at a time (`fetchWorkspaceOnce`).
*
* Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, `fetchEnvFor`,
* `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO
* half runs one bounded `git fetch --quiet` per repository.
*
* Rules the code keeps and the tests pin:
* - The repository set is `resolveWorkspaceRepos`, the single source the panel and the diff route
* already use, so Fetch cannot reach a repository the panel would not show. The Docker rule is
* checked before any git runs: a repository a container can write to is refused outright, because a
* planted config would run on the host, and `git fetch` is exactly the command that can run a
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the automatic maintenance
* that follows a fetch) and its config commands such as `credential.helper`, which is why a timer only
* runs it after the user opts in.
* - One run per folder at a time: `fetchWorkspaceOnce` joins a run already going for the same folder
* instead of starting a second one, because two fetches in one clone collide on the ref lock.
* - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at
* most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the
* whole run: one `Limiter`, shared by the scheduler's tick, not one bound per folder.
* - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a
* login fails fast instead of hanging the request on an invisible username prompt. Codeman collects
* no credentials; a repository the user's own git is already signed in to works because that is the
* user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is
* a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the
* user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups
* that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new`
* writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a
* write.
* - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns
* `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go
* out while a helper is still running, and on a timer that repeats every tick with nobody watching.
* `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL
* `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used
* rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue:
* fetch is bounded by `FETCH_CONCURRENCY`, not by a clone queue.
* - Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`),
* and the browser writes it with `textContent` only.
*
* @module git-fetch
*/
import { execFile, spawn } from 'node:child_process';
import { basename, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import { classifyGitFailure, gitNonInteractiveEnv, sanitizeGitOutput, type GitFailureCode } from './git-clone.js';
import {
createLimiter,
DEFAULT_GIT_TIMEOUT_MS,
type Limiter,
resolveOverviewLimits,
resolveWorkspaceRepos,
type GitRunner,
} from './git-workspace-status.js';
const execFileAsync = promisify(execFile);
/**
* Git processes in flight at once, for one run, and for a whole scheduler tick, because the tick hands ONE
* bound to every run (see `createLimiter`): each fetch is one git process, and a folder can hold up to
* `MAX_REPOS_LIMIT`. The tick bounds its folders separately, because the same semaphore cannot serve both
* levels: a folder holding a slot would then wait for a slot its own fetch needs.
*/
export const FETCH_CONCURRENCY = 4;
/** A fetch on a busy remote can print a lot; a bound on what we will hold. */
const MAX_FETCH_OUTPUT_BYTES = 2 * 1024 * 1024;
/** git's stderr is kept as a bounded tail (as in git-clone.ts): enough for the reason, not the whole stream. */
const MAX_FETCH_STDERR_BYTES = 8_192;
/** A `git config --get` answer is one line; this is a sanity cap, not an expected size. */
const MAX_SSH_COMMAND_BYTES = 4_096;
/**
* How long the process group gets to answer SIGTERM before the same escalation goes to SIGKILL. Kept as a
* const so a test can say how long it is willing to wait for the group to actually die.
*/
export const FETCH_KILL_ESCALATION_MS = 3_000;
/**
* Config overrides set ahead of the argv, so an unattended fetch cannot do more than move refs: no
* fsmonitor hook, and no automatic maintenance or gc. A fetch is the git command that can run a
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the maintenance that follows a
* fetch), and a gc started by a timer in a repository an agent is working in is exactly the kind of extra
* write this module must not do. Kept as a const so a test can pin it without spawning git.
*/
export const FETCH_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
/** The interval the user can turn on (Settings → Bottom bar, and Session Options → Git): bounds and default. */
export const DEFAULT_GIT_FETCH_INTERVAL_SECONDS = 300;
export const MIN_GIT_FETCH_INTERVAL_SECONDS = 30;
export const MAX_GIT_FETCH_INTERVAL_SECONDS = 3600;
export interface GitFetchSettings {
enabled: boolean;
intervalSeconds: number;
}
/**
* A settings object as the scheduler should read it: absent means OFF, because nothing runs on a timer
* until the user turns this on, and a number outside the schema's bounds is clamped rather than trusted.
* Pure, so the reading itself is testable without a settings file.
*/
export function gitFetchSettingsFrom(settings: Record<string, unknown> | undefined): GitFetchSettings {
const raw = settings?.gitFetchIntervalSeconds;
const seconds =
typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_GIT_FETCH_INTERVAL_SECONDS;
return {
enabled: settings?.gitFetchEnabled === true,
intervalSeconds: Math.min(MAX_GIT_FETCH_INTERVAL_SECONDS, Math.max(MIN_GIT_FETCH_INTERVAL_SECONDS, seconds)),
};
}
/**
* The only git command this module runs. Kept as a function so a test can pin the verb: nothing here
* commits, checks out, merges or prunes, and no operand is interpolated: the repository is the process
* `cwd` and the argv is fixed.
*/
export function buildFetchArgs(): string[] {
return ['fetch', '--quiet'];
}
/**
* The `git` process also gets the `FETCH_CONFIG_ARGS` overrides ahead of this argv; they are kept separate
* so pinning the verb here does not have to say anything about maintenance, and so both can be tested
* without spawning.
*
* A repository with no remote is a fact, not a fault. Git's wordings for it differ by version (2.47
* exits quietly and this code is never reached, older git says "no default remote repository"), so the
* branch is pinned on literal fixtures and the code the panel shows is the code this writes.
*/
export type FetchFailureCode = GitFailureCode | 'NO_REMOTE';
export interface FetchFailure {
code: FetchFailureCode;
/** User-facing summary. */
message: string;
/** Tail of git's own stderr, control-stripped and credential-redacted. */
stderr: string;
}
/**
* A thrown git error as the panel should show it. `classifyGitFailure` (git-clone.ts) already knows the
* network reasons (auth, an unreachable host, a missing git), so they are reused here; only its timeout
* wording is written for clones, so the fetch path replaces it with what the user can actually do.
*/
export function describeFetchFailure(err: unknown): FetchFailure {
const e = err as { code?: unknown; stderr?: unknown; message?: string; killed?: boolean };
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
const spawnError = typeof e.message === 'string' ? e.message : undefined;
const clean = sanitizeGitOutput(stderr);
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
if (/no default remote repository|no remote configured|nothing to fetch/.test(lower)) {
return {
code: 'NO_REMOTE',
message: 'This repository has no remote, so there is nothing to fetch.',
stderr: clean,
};
}
const timedOut = e.code === 'ETIMEDOUT' || e.killed === true;
const failure = classifyGitFailure(stderr, timedOut, spawnError);
if (failure.code === 'TIMEOUT')
failure.message = 'Git timed out. Raise “Git status: git timeout” in Settings → Bottom bar, or fetch from a shell.';
return failure;
}
/**
* The environment a fetch gets, as a pure decision so it can be pinned without spawning git.
*
* `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads
* `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would
* override a repository that already chose its own ssh (an alternative host, an identity file, a jump box)
* and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it
* after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would
* hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the
* whole decision here: the caller supplies the value it read, and this decides between the user's
* environment and the repository's own answer.
*/
export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv {
const env = gitNonInteractiveEnv(base);
// `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the
// case where it was a choice rather than a pass-through, and the repository's own answer, or a `GIT_SSH` in
// the environment, wins over it.
if (!base.GIT_SSH_COMMAND && (repoSshCommand || base.GIT_SSH)) delete env.GIT_SSH_COMMAND;
return env;
}
/**
* The repository's own `core.sshCommand`, or null. One bounded, read-only `git config --get` per fetch:
* git would honour that config itself if we did not write `GIT_SSH_COMMAND` over it, so honouring it means
* not writing one. `--get` reads local and global config, which is the value git would have used, and no
* hook or filter runs on the way to it.
*
* The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
*/
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
try {
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
cwd,
timeout: timeoutMs,
maxBuffer: MAX_SSH_COMMAND_BYTES,
env: gitNonInteractiveEnv(),
});
return stdout.trim() || null;
} catch {
// A repository with no such setting, or a read that could not finish: the default is what git would have
// used anyway, and a fetch must not fail because the check for a config value failed.
return null;
}
}
/**
* Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a
* required lock anyway.
*
* `spawn`, not `execFile`, because the bound has to reach the whole tree (see the fileoverview): the
* caller is answered at the timeout, and the SIGTERM → SIGKILL escalation is deliberately NOT cancelled
* when the answer goes out, because the promise is a promise about the answer while the group still has to
* die. That is the difference from `runGit` in git-clone.ts, which disarms the escalation at `finish` and so
* can leave a `git-remote-http` child running after a timed-out call.
*
* The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a
* spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit
* rejects the way `execFile` would (`code` is then the exit code), because resolving it would report a real
* failure as a clean run with an empty reason.
*
* `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read
* left, so a `git` that never answers costs one timeout, not two. A read that used the whole bound answers
* as a timeout without starting the fetch.
*/
export const runFetch: GitRunner = async (cwd, args, opts) => {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
const startedAt = Date.now();
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
const remainingMs = timeoutMs - (Date.now() - startedAt);
if (remainingMs <= 0) {
throw Object.assign(new Error(`Command failed: git ${args.join(' ')}`), {
stderr: '',
killed: true,
code: 'ETIMEDOUT',
});
}
return new Promise<string>((resolve, reject) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', [...FETCH_CONFIG_ARGS, ...args], {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
reject(err);
return;
}
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const fail = (spawnError?: string, exitCode?: number | null) => {
if (settled) return;
settled = true;
// Only the timeout timer is disarmed here. On a timeout the escalation is what the caller is waiting
// for, and cancelling it is the bug this runner exists to fix.
clearTimeout(timeoutTimer);
reject(
Object.assign(new Error(spawnError ?? `Command failed: git ${args.join(' ')}`), {
stderr,
killed: timedOut,
code: timedOut ? 'ETIMEDOUT' : (exitCode ?? undefined),
})
);
};
const timeoutTimer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS);
fail();
}, remainingMs);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= MAX_FETCH_OUTPUT_BYTES) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
if (stderr.length > MAX_FETCH_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_FETCH_STDERR_BYTES);
});
const finish = (code: number | null) => {
if (settled) return;
// execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real
// git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null`
// means a signal, reachable only when something outside this runner killed the group: our own timeout
// has already answered by then, and `settled` above is what keeps that answer the answer.
if (code !== 0) return fail(undefined, code);
settled = true;
clearTimeout(timeoutTimer);
if (killTimer) clearTimeout(killTimer);
resolve(stdout);
};
child.on('error', (err) => fail(String(err)));
child.on('close', (code) => finish(code));
});
};
export interface GitFetchEntry {
/** Folder name of the repository (its root's basename), as the panel names it. */
name: string;
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
path: string;
ok: boolean;
failure?: FetchFailure;
}
export interface GitFetchReport {
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
/** One entry per repository fetched, in the same order the panel lists them. */
repos: GitFetchEntry[];
/** More than `repoLimit` repositories were found; only the first were fetched. */
reposTruncated: boolean;
/** The most repositories this fetch would list (the caller's setting, or `MAX_REPOS`). */
repoLimit?: number;
/** When the last repository finished, so the panel can say what the "behind" number is now based on. */
fetchedAt: number;
}
/**
* What one run did, in the shape a session keeps and the Session Options Git row shows. `trigger` says
* whether the user clicked or the interval fired, because a number the user did not ask for should read
* differently from one they did.
*/
/** What a run was, and why it happened. */
export type GitFetchTrigger = 'click' | 'interval';
export interface GitFetchStatus {
ranAt: number;
trigger: GitFetchTrigger;
/** As in the report: `ok`, or the state that stopped the run. */
state: GitFetchReport['state'];
/** Repositories this run covered. */
repos: number;
/** How many of them could not be fetched. */
failed: number;
/** The first failure, named: enough for one row without reprinting the whole list. */
firstFailure?: { name: string; code: FetchFailureCode };
}
/**
* One run, with the trigger that caused it. A joiner gets the run already going, so what it reports is that
* run's trigger rather than the one that made it ask: a click that joins an interval run did not fetch
* anything itself, and the row the panel shows has to describe the fetch that actually happened.
*/
export interface GitFetchRun {
report: GitFetchReport;
trigger: GitFetchTrigger;
}
export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchTrigger): GitFetchStatus {
const failed = report.repos.filter((r) => !r.ok);
const status: GitFetchStatus = {
ranAt: report.fetchedAt,
trigger,
state: report.state,
repos: report.repos.length,
failed: failed.length,
};
const first = failed[0];
if (first?.failure) status.firstFailure = { name: first.name, code: first.failure.code };
return status;
}
/** Options for the fetch path: the same repository rules and limits as an overview, without its cache clock. */
export interface GitFetchOptions {
git?: GitRunner;
home?: string;
dockerWorkspaces?: string[];
maxRepos?: number;
timeoutMs?: number;
/**
* The shared slot count for a whole scheduler tick. Passed in so `FETCH_CONCURRENCY` bounds the tick
* rather than each folder separately; without it, a tick over more than `FETCH_CONCURRENCY` folders could
* have that many git processes going at once inside each one.
*/
limiter?: Limiter;
}
export const emptyReport = (state: GitFetchReport['state'], extra: Partial<GitFetchReport> = {}): GitFetchReport => ({
state,
repos: [],
reposTruncated: false,
fetchedAt: Date.now(),
...extra,
});
async function fetchOne(dir: string, git: GitRunner, timeoutMs: number): Promise<FetchFailure | null> {
try {
await git(dir, buildFetchArgs(), { timeoutMs });
return null;
} catch (err) {
return describeFetchFailure(err);
}
}
/**
* Fetch every repository the panel would show for `cwd`, through the same resolver so the two cannot
* disagree. A repository that failed keeps its row with the reason, exactly as an unreadable repository
* stays in the status list: the panel must show that something was not fetched rather than quietly skip it.
*/
export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptions = {}): Promise<GitFetchReport> {
const where = await resolveWorkspaceRepos(cwd, opts);
if (where.kind === 'docker') return emptyReport('unsupported', { reason: 'docker' });
if (where.kind === 'error') return emptyReport('error', { error: where.error });
const dirs = where.kind === 'enclosing' ? [where.root] : where.dirs;
if (!dirs.length) return emptyReport('not-a-repo');
const { timeoutMs } = resolveOverviewLimits(opts);
const runner = opts.git ?? runFetch;
const run = opts.limiter ?? createLimiter(FETCH_CONCURRENCY);
const failures = await run(dirs, (dir) => fetchOne(dir, runner, timeoutMs));
const repos: GitFetchEntry[] = dirs.map((dir, i) => {
const base = { name: basename(dir), path: relative(cwd, dir) || '.' };
const failure = failures[i];
return failure ? { ...base, ok: false, failure } : { ...base, ok: true };
});
return {
state: 'ok',
repos,
reposTruncated: where.kind === 'children' ? where.truncated : false,
repoLimit: resolveOverviewLimits(opts).maxRepos,
fetchedAt: Date.now(),
};
}
/**
* Runs already going, keyed by the resolved folder. A request for a folder another run is already working on
* JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on
* git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would
* otherwise do. A joiner gets the run already going's answer, including its limits and its trigger: the point
* is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot
* report itself as the fetch.
*
* Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516
* bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that
* resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so
* a link and its target can be fetched by two runs, the accepted trade for keeping the probe bounded.
*/
const lane = new Map<string, Promise<GitFetchRun>>();
export async function fetchWorkspaceOnce(
folder: string,
opts: GitFetchOptions = {},
trigger: GitFetchTrigger
): Promise<GitFetchRun> {
const key = resolve(folder);
const going = lane.get(key);
// The joiner is handed the run already going as it is, trigger included: it did not cause that fetch, so it
// must not report itself as having.
if (going) return going;
const run: Promise<GitFetchRun> = fetchGitWorkspaceOverview(key, opts)
.then((report) => ({ report, trigger }))
.finally(() => {
// Only the run that is still the lane's own: a later run for the same folder must not be dropped by an
// earlier one finishing late.
if (lane.get(key) === run) lane.delete(key);
});
lane.set(key, run);
return run;
}
/** Test seam: an in-memory lane is not cleared by stopping the scheduler, so a test has to clear it itself. */
export function clearGitFetchLane(): void {
lane.clear();
}
+55 -6
View File
@@ -22,7 +22,8 @@
*
* Rules the code keeps and the tests pin:
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
* the last fetch (the UI says so, and the Fetch button is a separate user-triggered path in
* `src/git-fetch.ts`, never a poll); "ahead" and the unpushed list are exact against the
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
* refreshing the index, so polling cannot contend with the agent's own git commands.
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
@@ -596,8 +597,55 @@ export async function discoverChildRepos(
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
/**
* A slot count fixed ONCE, so callers share it instead of each getting its own bound (see the fetch path):
* the count lives in the closure, so `limit` is the ceiling for everything going through THIS limiter at
* once, not for each call to it, which is what makes a scheduler tick's bound honest.
* ⚠️ One limiter cannot bound two levels of the same work: a caller that holds a slot and then waits for
* another slot for the work IT starts cannot get one, because the holders are exactly the ones waiting. The
* outer level needs its own bound (`mapLimited`), which is why the tick shares one for fetches and keeps a
* separate one for folders.
*/
export type Limiter = <T, R>(items: T[], fn: (item: T) => Promise<R>) => Promise<R[]>;
export function createLimiter(limit: number): Limiter {
let inUse = 0;
const waiting: Array<() => void> = [];
const acquire = (): Promise<void> => {
if (inUse < limit) {
inUse++;
return Promise.resolve();
}
return new Promise<void>((done) => {
waiting.push(() => {
inUse++;
done();
});
});
};
const release = () => {
inUse--;
const next = waiting.shift();
if (next) next();
};
return async <T, R>(items: T[], fn: (item: T) => Promise<R>): Promise<R[]> => {
const out: R[] = new Array(items.length);
await Promise.all(
items.map(async (item, i) => {
await acquire();
try {
out[i] = await fn(item);
} finally {
release();
}
})
);
return out;
};
}
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. Per call; `createLimiter` shares one bound across calls. */
export async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
@@ -634,7 +682,7 @@ export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: un
};
}
type WorkspaceRepos =
export type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
@@ -643,9 +691,10 @@ type WorkspaceRepos =
/**
* WHICH repositories belong to the workspace (the module header has the rules), without a full
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
* below the folder. The overview, the fetch route and the diff route all go through here, so they cannot
* disagree about which repositories belong to a workspace.
*/
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
export async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
const now = opts.now ?? Date.now;
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
+38 -3
View File
@@ -42,6 +42,7 @@ import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { isNearStalledPath, probePath } from './utils/index.js';
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
import { gitFetchSettingsFrom, type GitFetchSettings } from './git-fetch.js';
/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
@@ -1158,8 +1159,9 @@ export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
/**
* The same two keys for the keep-warm service, which keeps what it reads until the next
* settings change. A missing file is the defaults, but any other read or parse failure rejects
* rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
* rather than falling back: settings.json is replaced atomically, so such a failure is a real
* read error or a file that does not parse (a hand edit), never a write in progress, and a
* fallback kept from it would hold keep-warm off until the next keep-warm settings change.
*/
export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
let text: string;
@@ -1169,7 +1171,40 @@ export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
throw err;
}
return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>);
return keepWarmSettingsFrom(parseSettingsText(text));
}
/**
* The Git fetch interval's app-wide default (`gitFetchEnabled`, absent = OFF) and its cap
* (`gitFetchIntervalSeconds`, seconds between runs). Read FRESH on every tick by the scheduler, so a live
* toggle takes effect on the next tick and turning it off actually stops the fetching. A missing file is
* the defaults (OFF), but any other read or parse failure rejects and the scheduler skips that tick rather
* than deciding from defaults. settings.json is replaced atomically (a temp file renamed into place, see
* settings-writer.ts), so such a failure is a real read error or a file that does not parse (a hand edit),
* never a write in progress, and the scheduler logs it once rather than every tick.
*/
export async function readGitFetchSettings(): Promise<GitFetchSettings> {
let text: string;
try {
text = await readFile(SETTINGS_PATH, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return gitFetchSettingsFrom({});
throw err;
}
return gitFetchSettingsFrom(parseSettingsText(text));
}
/**
* settings.json's text for the two readers above, which reject a file that does not parse. The parser's
* own message is never repeated, as in settings-writer.ts: V8 quotes the text around the fault, settings.json
* can hold an API key (`voiceSettings.apiKey`), and both callers log the error they get.
*/
function parseSettingsText(text: string): Record<string, unknown> {
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`${SETTINGS_PATH} is not valid JSON`);
}
}
function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings {
+510
View File
@@ -0,0 +1,510 @@
/**
* @fileoverview Optional host readings for the header stats: GPU load and memory, and the live CPU and GPU clock
* speeds.
*
* Every reader is opt-in per request (the route only calls the ones the browser asks for, and the browser only
* asks when the matching setting is on), answers `undefined` when the host has no such sensor, and never
* throws. Reads are cached for a couple of seconds so several open tabs cost one read.
*
* Sources, with no extra dependency:
* - GPU: `nvidia-smi` (NVIDIA), else the amdgpu sysfs files (`gpu_busy_percent`, `mem_info_vram_*`,
* `pp_dpm_sclk`), else, inside WSL, the Windows performance counters through `typeperf.exe` (any vendor,
* Intel included: load and memory in use, but Windows publishes no GPU clock for them).
* - CPU clock: inside WSL the Windows `Processor Frequency` counter (WSL's own cpuinfo is a fixed number),
* else the mean of `scaling_cur_freq` across cores, else the `cpu MHz` lines of /proc/cpuinfo.
*
* The parsers are pure and exported so the tests need no hardware.
*
* @module host-sensors
*/
import { execFile, spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
export interface GpuReading {
/** Core utilisation, 0 to 100. */
percent: number;
/** Graphics memory in use as a share of what is available, 0 to 100, when that is known. */
memPercent?: number;
memUsedMB?: number;
memTotalMB?: number;
/** Current graphics clock in MHz, when known. */
mhz?: number;
}
export interface HostSensors {
gpu?: GpuReading;
cpuMhz?: number;
}
export interface SensorRequest {
/** GPU load, graphics memory and clock. */
gpu?: boolean;
cpuMhz?: boolean;
}
const CACHE_MS = 2000;
/** A counter sample older than this (a few sampling intervals) is no longer a live reading. */
const FRESH_MS = 10000;
/** After a reader fails, leave it alone this long: nvidia-smi absent or timing out, PowerShell failing. */
const READER_BACKOFF_MS = 60000;
/** The GPU counters stay in the sampler this long after the last GPU read that needed them. */
const GPU_COUNTERS_HOLD_MS = 120000;
const EXEC_TIMEOUT_MS = 1500;
const SYS_DRM = '/sys/class/drm';
const SYS_CPU = '/sys/devices/system/cpu';
const TYPEPERF = '/mnt/c/Windows/System32/typeperf.exe';
const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';
const MB = 1048576;
const clampPercent = (n: number) => Math.min(100, Math.max(0, Math.round(n)));
const finite = (n: number) => (Number.isFinite(n) ? n : undefined);
/** Parse `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr --format=csv,noheader,nounits`. */
export function parseNvidiaSmi(output: string): GpuReading | undefined {
const line = output.split('\n').find((l) => l.trim());
if (!line) return undefined;
const [util, used, total, clock] = line.split(',').map((s) => finite(Number(s.trim())));
if (util === undefined) return undefined;
const reading: GpuReading = { percent: clampPercent(util) };
if (used !== undefined && total !== undefined && total > 0) {
reading.memUsedMB = Math.round(used);
reading.memTotalMB = Math.round(total);
reading.memPercent = clampPercent((used / total) * 100);
}
if (clock !== undefined && clock > 0) reading.mhz = Math.round(clock);
return reading;
}
/** The active line of an amdgpu `pp_dpm_sclk` table (`1: 1800Mhz *`). */
export function parseDpmClock(text: string): number | undefined {
const active = text.split('\n').find((l) => l.trim().endsWith('*'));
const mhz = active ? /(\d+)\s*mhz/i.exec(active)?.[1] : undefined;
return mhz ? Number(mhz) : undefined;
}
/** Mean of per-core `scaling_cur_freq` values (kHz) as MHz. */
export function meanKhzAsMhz(values: number[]): number | undefined {
const good = values.filter((v) => Number.isFinite(v) && v > 0);
if (!good.length) return undefined;
return Math.round(good.reduce((a, b) => a + b, 0) / good.length / 1000);
}
/** Mean of the `cpu MHz : 3412.5` lines of /proc/cpuinfo. */
export function parseCpuInfoMhz(text: string): number | undefined {
const values = [...text.matchAll(/^cpu MHz\s*:\s*([\d.]+)/gim)].map((m) => Number(m[1]));
return meanKhzAsMhz(values.map((v) => v * 1000));
}
export interface WindowsCounters {
gpu?: { percent: number; memUsedMB: number; dedicated: boolean };
cpuMhz?: number;
}
const ENGINE =
/GPU Engine\(.*?_luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+_eng_\d+_engtype_([^)]*)\)\\Utilization Percentage/i;
const ADAPTER_MEMORY = /GPU Adapter Memory\(luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+\)\\(Shared|Dedicated) Usage/i;
const CPU_FREQUENCY = /Processor Information\(_Total\)\\Processor Frequency/i;
/**
* Parse one `typeperf.exe -sc 1 ...` sample: a quoted CSV header of counter paths and one row of values.
* Task Manager's GPU figure is its busiest engine, so per adapter the load is the largest per-engine-type sum
* (3D, copy, video decode...), capped at 100. The adapter that holds the most graphics memory is the real one
* (Windows also lists software adapters that hold a few kilobytes). A `Shared Usage` only means an integrated
* GPU, whose memory is carved out of system RAM.
*/
export function parseTypeperf(output: string): WindowsCounters {
const rows = output
.split(/\r?\n/)
.filter((l) => l.startsWith('"'))
.map((l) => l.slice(1, l.lastIndexOf('"')).split('","'));
if (rows.length < 2) return {};
const [names, values] = rows;
const engines = new Map<string, Map<string, number>>();
const memory = new Map<string, { shared: number; dedicated: number }>();
let cpuMhz: number | undefined;
names.forEach((name, i) => {
const value = finite(Number(values[i]));
if (value === undefined || values[i].trim() === '') return;
const engine = ENGINE.exec(name);
if (engine) {
const byType = engines.get(engine[1].toLowerCase()) ?? new Map<string, number>();
byType.set(engine[2], (byType.get(engine[2]) ?? 0) + value);
engines.set(engine[1].toLowerCase(), byType);
return;
}
const mem = ADAPTER_MEMORY.exec(name);
if (mem) {
const luid = mem[1].toLowerCase();
const entry = memory.get(luid) ?? { shared: 0, dedicated: 0 };
if (mem[2].toLowerCase() === 'shared') entry.shared = value;
else entry.dedicated = value;
memory.set(luid, entry);
return;
}
if (CPU_FREQUENCY.test(name) && value > 0) cpuMhz = Math.round(value);
});
const result: WindowsCounters = {};
if (cpuMhz) result.cpuMhz = cpuMhz;
const luids = new Set([...engines.keys(), ...memory.keys()]);
let best: { luid: string; bytes: number; load: number } | undefined;
for (const luid of luids) {
const m = memory.get(luid);
const bytes = (m?.shared ?? 0) + (m?.dedicated ?? 0);
const load = Math.max(0, ...(engines.get(luid)?.values() ?? []));
if (!best || bytes > best.bytes || (bytes === best.bytes && load > best.load)) best = { luid, bytes, load };
}
if (best && (engines.size > 0 || memory.size > 0)) {
result.gpu = {
percent: clampPercent(best.load),
memUsedMB: Math.round(best.bytes / MB),
dedicated: (memory.get(best.luid)?.dedicated ?? 0) > 0,
};
}
return result;
}
/**
* Run a reader, answering undefined on failure or once `timeout` has passed. execFile's own `timeout` only
* sends the child a signal, and its callback waits for the child to exit: an nvidia-smi stuck on a hung
* driver (uninterruptible sleep) never does, and every stats request would wait on it with CPU and MEM. So
* the deadline here answers by itself; the child is left to the signal.
*/
const run = (cmd: string, args: string[], timeout = EXEC_TIMEOUT_MS) =>
new Promise<string | undefined>((resolve) => {
const deadline = setTimeout(() => resolve(undefined), timeout);
deadline.unref();
execFile(cmd, args, { timeout, windowsHide: true, maxBuffer: 4 * MB }, (err, stdout) => {
clearTimeout(deadline);
resolve(err ? undefined : stdout);
});
});
const readText = (path: string) =>
readFile(path, 'utf8').then(
(t) => t.trim(),
() => undefined
);
const readNumber = async (path: string) => {
const text = await readText(path);
return text === undefined ? undefined : finite(Number(text));
};
/** Linux running under WSL: Windows programs can be started, and the GPU belongs to Windows. */
let wsl: boolean | undefined;
function inWsl(): boolean {
wsl ??=
process.platform === 'linux' &&
(Boolean(process.env.WSL_INTEROP) ||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') ||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late'));
return wsl;
}
let windowsRamMB: number | undefined;
let ramProbe: Promise<void> | undefined;
let ramRetryAt = 0;
/**
* Windows' installed RAM in MB once PowerShell has answered, else undefined. The probe (about a second, 8 s
* timeout) is started here but never awaited: the stats route answers CPU and MEM only after the sensors, so
* the GPU reading carries the memory in use alone until the answer lands, and the total from then on. A
* failure is not retried on every GPU poll: it waits READER_BACKOFF_MS first.
*/
function windowsRamMBIfKnown(): number | undefined {
if (windowsRamMB !== undefined || ramProbe || Date.now() < ramRetryAt) return windowsRamMB;
ramProbe = run(
POWERSHELL,
['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'],
8000
).then((out) => {
const bytes = out ? finite(Number(out.trim())) : undefined;
if (bytes && bytes > 0) windowsRamMB = Math.round(bytes / MB);
else ramRetryAt = Date.now() + READER_BACKOFF_MS;
ramProbe = undefined;
});
return undefined;
}
/** What the sampler needs from a child process; `ChildProcess` fits, and tests pass a fake. */
export type SamplerChild = Pick<ChildProcess, 'stdout' | 'kill' | 'once'>;
export interface CounterStreamOptions {
start: (counters: string[]) => SamplerChild;
now?: () => number;
/**
* How long a read waits for a sample when it has none fresh (0, the default, never waits: the read
* answers undefined and the next poll shows the reading). The stats route awaits the sensors before it
* answers CPU and MEM, so a cold sampler must not hold it up.
*/
firstSampleMs?: number;
/** A sampler older than this is replaced, so counters of processes that started since are picked up. */
recycleMs?: number;
/** A sampler nobody has read for this long is stopped. */
idleMs?: number;
/** After the sampler fails to start or exits at once, do not try again for this long. */
backoffMs?: number;
}
/**
* One long-running `typeperf.exe -si 2` instead of a process per read: every `typeperf` sample takes over a
* second (rate counters need two collections), which would keep one running back to back. The newest row is kept
* and handed out; the process is replaced every `recycleMs` (its wildcard counters are expanded once, at
* start) and stopped after `idleMs` without a read, so nothing runs while the settings are off.
*/
export function createCounterStream(options: CounterStreamOptions) {
const now = options.now ?? Date.now;
const firstSampleMs = options.firstSampleMs ?? 0;
const recycleMs = options.recycleMs ?? 30000;
const idleMs = options.idleMs ?? 30000;
const backoffMs = options.backoffMs ?? 60000;
let child: SamplerChild | undefined;
let key = '';
let startedAt = 0;
let lastRead = 0;
let blockedUntil = 0;
let latest: { at: number; value: WindowsCounters } | undefined;
let waiting: Array<() => void> = [];
const stop = () => {
const old = child;
child = undefined;
if (old) {
try {
old.kill();
} catch {
/* already gone */
}
}
};
const begin = (counters: string[]) => {
stop();
const nextKey = counters.join('\n');
// A sample of other counters is no answer; one from an older sampler of the same counters still is.
if (nextKey !== key) latest = undefined;
key = nextKey;
startedAt = now();
// The header row and the partial line belong to this process alone: a late row from a replaced sampler
// must never become the next one's header.
let header: string | undefined;
let buffer = '';
let sampled = false;
let started: SamplerChild;
try {
started = options.start(counters);
} catch {
blockedUntil = now() + backoffMs;
return;
}
child = started;
started.stdout?.on('data', (chunk: Buffer | string) => {
if (child !== started) return;
buffer += chunk.toString();
let at: number;
while ((at = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, at).replace(/\r$/, '');
buffer = buffer.slice(at + 1);
if (!line.startsWith('"')) continue;
if (!header) {
header = line;
continue;
}
sampled = true;
latest = { at: now(), value: parseTypeperf(`${header}\n${line}`) };
const release = waiting;
waiting = [];
release.forEach((fn) => fn());
}
});
const ended = () => {
if (child !== started) return;
child = undefined;
// It ended without a sample (typeperf missing, counters unknown): leave it alone for a while.
if (!sampled) blockedUntil = now() + backoffMs;
const release = waiting;
waiting = [];
release.forEach((fn) => fn());
};
started.once('exit', ended);
started.once('error', ended);
};
return {
/** The newest fresh sample for these counters, or undefined (a cold sampler is not waited for unless `firstSampleMs` says so). */
async read(counters: string[]): Promise<WindowsCounters | undefined> {
lastRead = now();
if (now() < blockedUntil) return undefined;
const wanted = counters.join('\n');
if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters);
if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) {
if (!child) return undefined;
if (firstSampleMs > 0) {
await new Promise<void>((resolve) => {
waiting.push(resolve);
setTimeout(resolve, firstSampleMs).unref();
});
}
}
return latest && now() - latest.at < FRESH_MS ? latest.value : undefined;
},
/** Stop the sampler when nobody has read it lately. */
sweep() {
if (child && now() - lastRead > idleMs) {
stop();
latest = undefined;
}
},
stop,
};
}
const GPU_COUNTERS = [
'\\GPU Engine(*)\\Utilization Percentage',
'\\GPU Adapter Memory(*)\\Shared Usage',
'\\GPU Adapter Memory(*)\\Dedicated Usage',
];
const CPU_COUNTERS = ['\\Processor Information(_Total)\\Processor Frequency'];
/**
* The counters one sampler reads. They do not depend on which reading a browser asked for (those are per
* device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added on a
* machine with no native GPU reader (and dropped again once nothing has needed them for a while). A request then only picks its fields from the sample.
*/
export function windowsCounterList(needGpu: boolean): string[] {
return needGpu ? [...GPU_COUNTERS, ...CPU_COUNTERS] : [...CPU_COUNTERS];
}
export function createWindowsSource(
stream: Pick<ReturnType<typeof createCounterStream>, 'read' | 'stop'>,
now: () => number = Date.now
) {
let gpuUntil = 0;
return {
/**
* A GPU reading is wanted and no native reader answered: sample the GPU counters too, for
* GPU_COUNTERS_HOLD_MS after the last such read. A single failed nvidia-smi therefore costs a sampler
* restart, not a GPU sampler for the life of the server.
*/
needGpuCounters() {
gpuUntil = now() + GPU_COUNTERS_HOLD_MS;
},
read: () => stream.read(windowsCounterList(now() < gpuUntil)),
stop: () => stream.stop(),
};
}
const windowsCounters = createCounterStream({
// Spawn the Windows program directly: if it is missing, the error lands in the stream's back-off.
start: (counters) =>
spawn(TYPEPERF, ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }),
});
const windowsSource = createWindowsSource(windowsCounters);
let sweepTimer: ReturnType<typeof setInterval> | undefined;
/** Stop the background sampler and its idle sweep (the server calls this on shutdown). */
export function stopHostSensors(): void {
if (sweepTimer) clearInterval(sweepTimer);
sweepTimer = undefined;
windowsSource.stop();
}
async function readAmdGpu(): Promise<GpuReading | undefined> {
const cards = (await readdir(SYS_DRM).catch(() => [] as string[])).filter((n) => /^card\d+$/.test(n)).sort();
for (const card of cards) {
const dev = join(SYS_DRM, card, 'device');
const busy = await readNumber(join(dev, 'gpu_busy_percent'));
if (busy === undefined) continue;
const reading: GpuReading = { percent: clampPercent(busy) };
const used = await readNumber(join(dev, 'mem_info_vram_used'));
const total = await readNumber(join(dev, 'mem_info_vram_total'));
if (used !== undefined && total !== undefined && total > 0) {
reading.memUsedMB = Math.round(used / MB);
reading.memTotalMB = Math.round(total / MB);
reading.memPercent = clampPercent((used / total) * 100);
}
const sclk = await readText(join(dev, 'pp_dpm_sclk'));
const mhz = sclk ? parseDpmClock(sclk) : undefined;
if (mhz) reading.mhz = mhz;
return reading;
}
return undefined;
}
/** nvidia-smi is not run again before this time: it failed or timed out, and waits READER_BACKOFF_MS. */
let nvidiaSkipUntil = 0;
/** NVIDIA, then AMD. A host without a working nvidia-smi is not asked again on every poll. */
async function readNativeGpu(): Promise<GpuReading | undefined> {
if (Date.now() >= nvidiaSkipUntil) {
const out = await run('nvidia-smi', [
'--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr',
'--format=csv,noheader,nounits',
]);
const reading = out ? parseNvidiaSmi(out) : undefined;
if (reading) return reading;
nvidiaSkipUntil = Date.now() + READER_BACKOFF_MS;
}
return readAmdGpu();
}
/** A GPU reading from the Windows counters (WSL only): load and memory in use, but no clock. */
function gpuFromWindows(windows: WindowsCounters | undefined): GpuReading | undefined {
if (!windows?.gpu) return undefined;
const { percent, memUsedMB, dedicated } = windows.gpu;
const reading: GpuReading = { percent, memUsedMB };
// An integrated GPU draws its memory from system RAM, and Windows lets it use up to half of it. Until
// Windows has said how much RAM it has, the reading is the memory in use alone (the header shows no ring).
const ram = dedicated ? undefined : windowsRamMBIfKnown();
if (ram) {
reading.memTotalMB = Math.round(ram / 2);
reading.memPercent = clampPercent((memUsedMB / reading.memTotalMB) * 100);
}
return reading;
}
async function readCpuMhz(windows: WindowsCounters | undefined): Promise<number | undefined> {
// Under WSL the Linux side only has a fixed figure, so until Windows answers there is no reading.
if (windows) return windows.cpuMhz;
const cores = (await readdir(SYS_CPU).catch(() => [] as string[])).filter((n) => /^cpu\d+$/.test(n));
const khz = await Promise.all(cores.map((c) => readNumber(join(SYS_CPU, c, 'cpufreq', 'scaling_cur_freq'))));
const fromSysfs = meanKhzAsMhz(khz.filter((v): v is number => v !== undefined));
if (fromSysfs) return fromSysfs;
const info = await readText('/proc/cpuinfo');
return info ? parseCpuInfoMhz(info) : undefined;
}
const cache = new Map<string, { at: number; value: HostSensors }>();
const inFlight = new Map<string, Promise<HostSensors>>();
/** Read only what was asked for. Missing sensors are simply absent from the result. */
export async function readHostSensors(request: SensorRequest): Promise<HostSensors> {
const key = `${request.gpu ? 'g' : ''}${request.cpuMhz ? 'c' : ''}`;
if (!key) return {};
sweepTimer ??= setInterval(() => windowsCounters.sweep(), 10000).unref();
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
const running = inFlight.get(key);
if (running) return running;
const promise = (async () => {
// Native GPU readers first: a machine with a working nvidia-smi never starts the GPU counters.
const nativeGpu = request.gpu ? await readNativeGpu() : undefined;
const wsl = inWsl();
const windowsGpu = wsl && request.gpu && !nativeGpu;
if (windowsGpu) windowsSource.needGpuCounters();
const windows = wsl && (request.cpuMhz || windowsGpu) ? await windowsSource.read() : undefined;
const [gpu, cpuMhz] = await Promise.all([
request.gpu ? (nativeGpu ?? gpuFromWindows(windows)) : undefined,
request.cpuMhz ? readCpuMhz(wsl ? (windows ?? {}) : undefined) : undefined,
]);
const value: HostSensors = {};
if (gpu) value.gpu = gpu;
if (cpuMhz) value.cpuMhz = cpuMhz;
cache.set(key, { at: Date.now(), value });
return value;
})().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
+842
View File
@@ -0,0 +1,842 @@
/**
* @fileoverview Offline reader for saved Codex conversations, behind
* `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from
* disk directly: no HTTP route, no running server and no managed session.
*
* ## Selecting conversations
*
* Exactly one selector: `file` (one rollout, no store search), `sessionId` (a
* native thread id or unique prefix of at least 8 characters) or
* `rootSessionId` (a full id plus every transitive descendant). Identity always
* comes from each file's `session_meta`, never from its filename.
*
* ## Budgets
*
* Three separate bounds, each raisable from the CLI up to a fixed maximum, and
* each failing closed with an error rather than returning a partial answer:
*
* - `metadataBytes`: the head reads that attribute every rollout in the store.
* A `session_meta` line embeds Codex's base instructions (about 20 KiB on
* every rollout measured), so this scales with the store, not the query,
* and is kept apart from the transcript budget: a lookup on a store of
* thousands of rollouts must not spend the budget meant for the transcripts
* it returns. Each file's head read is also capped at 128 KiB.
* - `scanBytes`: the selected transcripts, read whole.
* - `maxEntries`: directory entries visited.
*
* ## Forks and sub-agents
*
* A thread Codex forks or spawns as a sub-agent can carry a copy of its
* parent's history in its own file (codex-rs `ForkPersistence::Copied`, written
* by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the
* parent's `session_meta`, see `keep_forked_rollout_item` in
* `core/src/agent/control/spawn.rs`). Such a file starts with its own
* `session_meta`, then the parent's, then the copied records, then one
* `thread_settings_applied` event, then the child's own records. The copy
* already lives in the parent's own file, so it is dropped here:
*
* - Paginated history (`history_mode: "paginated"`) numbers every record with
* an `ordinal` and declares `subagent_history_start_ordinal`; records below
* it are inherited, exactly as Codex's own projection treats them
* (`thread-store/src/local/thread_history_materialization.rs`). A paginated
* thread with a `history_base` references its prefix in another file and has
* nothing inherited to drop.
* - Legacy history has no ordinals. The prefix runs from the inherited
* `session_meta` to the `thread_settings_applied` event Codex appends after
* the copy. A legacy copy keeps the parent's own `thread_settings_applied`
* events too, so when more than one candidate follows, the boundary is the
* last one whose preceding messages are still a prefix of the parent's own
* messages, read from the parent's file (once: a family read that selects
* the parent reuses that same read, so no file is charged to `scanBytes`
* twice). Without a readable parent file that cannot be proven: the cut then
* falls at the LAST candidate, so parent text is never attributed to the
* child, and the result says so (`unverifiedPrefixes`, `complete: false`).
* Any failure of the parent's read other than a budget leads here, rather
* than failing the child whose own file is intact.
*
* A later `session_meta` must be the file's own id or a declared ancestor
* (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and
* transitively the ancestors those declare); any other id still refuses the
* whole file rather than attribute one conversation's answer to another.
*
* ## Compressed rollouts
*
* Codex's `local_thread_store_compression` feature rewrites cold rollouts to
* `*.jsonl.zst` in place. This reader does not decompress them: a store scan
* counts each one in `compressedFiles` (so `complete` is false and a not-found
* error names them), skipping one whose plain `.jsonl` sibling exists, as
* Codex's own discovery does. A Zstandard file given to `file` is refused by
* its magic number, whatever its name.
*
* ## Live rollouts
*
* A running Codex appends to its rollout. Reads take a snapshot: the file's
* identity (device and inode) must match the attributed one and its size may
* only have grown; everything up to the last complete line is read, and an
* unterminated tail is reported as a partial record.
*
* ## Format
*
* The line shapes and the injected-context rule are shared with the Past
* Sessions scanner through `codex-rollout-format.ts`.
*
* @module local-history
*/
import { constants } from 'node:fs';
import { isUtf8 } from 'node:buffer';
import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js';
const KIB = 1024;
const MIB = 1024 * KIB;
const GIB = 1024 * MIB;
export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB;
export const HISTORY_MAX_SCAN_BYTES = 256 * MIB;
export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB;
export const HISTORY_MAX_METADATA_BYTES = 16 * GIB;
export const HISTORY_DEFAULT_MAX_ENTRIES = 65536;
export const HISTORY_MAX_ENTRIES = 1048576;
export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192;
export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB;
/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */
const METADATA_HEAD_BYTES = 128 * KIB;
/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */
const METADATA_CHUNK_BYTES = 32 * KIB;
/**
* Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`:
* `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`).
*/
const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst';
/** The Zstandard frame magic number, little-endian on disk. */
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */
const MAX_DIRECTORY_DEPTH = 8;
const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i;
const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type Json = Record<string, unknown>;
export interface LocalHistoryOptions {
file?: string;
sessionId?: string;
rootSessionId?: string;
sessionsRoot?: string;
topic?: string;
/** Inclusive UTC date, `YYYY-MM-DD`. */
after?: string;
/** Inclusive UTC date, `YYYY-MM-DD`. */
before?: string;
scanBytes?: number;
metadataBytes?: number;
maxEntries?: number;
}
export interface HistoryRecord {
rootSessionId: string | null;
sessionId: string;
timestamp: string;
role: 'user' | 'assistant';
/** Codex's own phase for assistant messages (`commentary`, `final_answer`); null when none was recorded. */
phase: string | null;
message: string;
}
interface Source {
path: string;
sessionId: string;
parentId?: string;
forkedFromId?: string;
rootId?: string;
/** `subagent_history_start_ordinal`: records below it are inherited. */
historyStart?: number;
timestamp: string;
/** `dev:ino` of the attributed file. */
identity: string;
/** Size when attributed; a later read may only see it grow. */
size: number;
}
export interface LocalHistoryResult {
source: 'codex-jsonl';
complete: boolean;
sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>;
records: HistoryRecord[];
scan: {
/** Transcript bytes read (`--scan-bytes`). */
bytesRead: number;
/** Head bytes read to attribute rollouts (`--metadata-bytes`). */
metadataBytesRead: number;
entries: number;
malformedRecords: number;
partialRecords: number;
skippedSymlinks: number;
unattributedFiles: number;
/**
* Zstandard-compressed rollouts (`*.jsonl.zst`, Codex's `local_thread_store_compression`)
* this reader cannot open. Their threads are missing from the result.
*/
compressedFiles: number;
unreadableDirectories: number;
/** Inherited fork or sub-agent prefixes dropped (informational). */
inheritedPrefixes: number;
/** Inherited prefixes that never reached their end marker. */
incompletePrefixes: number;
/** Legacy prefixes whose end could not be checked against the parent's own file. */
unverifiedPrefixes: number;
};
}
interface Budget {
transcriptRemaining: number;
metadataRemaining: number;
entriesRemaining: number;
scan: LocalHistoryResult['scan'];
/** Paths behind `scan.compressedFiles`, for the not-found error. */
compressedPaths: string[];
}
/** A budget ran out: always fatal, never counted. */
class HistoryBudgetError extends Error {}
/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */
class UnattributableFileError extends Error {}
function object(value: unknown): Json {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {};
}
function string(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function fullId(value: unknown): string | undefined {
const id = string(value);
return id && FULL_ID.test(id) ? id.toLowerCase() : undefined;
}
function fileIdentity(value: { dev: number; ino: number }): string {
return `${value.dev}:${value.ino}`;
}
function validDate(value: string | undefined): boolean {
return (
value === undefined ||
(/^\d{4}-\d{2}-\d{2}$/.test(value) &&
Number.isFinite(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value)
);
}
/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */
function utcDate(timestamp: string): string | undefined {
const ms = Date.parse(timestamp);
return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined;
}
function checkBudget(value: number, max: number, flag: string): void {
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`Invalid ${flag} (1-${max})`);
}
function validate(options: LocalHistoryOptions): void {
if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1)
throw new Error('Select exactly one of --file, --session-id, or --root-session-id');
if (options.file !== undefined && !options.file) throw new Error('A nonempty file is required');
if (options.sessionId !== undefined && !ID.test(options.sessionId)) throw new Error('Invalid native session id');
if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId))
throw new Error('Invalid root session id');
if (
!validDate(options.after) ||
!validDate(options.before) ||
(options.after && options.before && options.after > options.before)
)
throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)');
if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic');
checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes');
checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes');
checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries');
}
async function regularFile(path: string): Promise<FileHandle> {
// O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat
// validates the opened descriptor, not a previously inspected pathname.
if (!(await lstat(path)).isFile()) throw new Error('History source must be a regular file, not a symlink');
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) {
await handle.close();
throw new Error('History source must be a regular file');
}
return handle;
}
async function readAt(handle: FileHandle, position: number, size: number): Promise<Buffer> {
const buffer = Buffer.alloc(size);
let filled = 0;
while (filled < size) {
const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled);
if (!bytesRead) break;
filled += bytesRead;
}
return buffer.subarray(0, filled);
}
async function readMetadataChunk(handle: FileHandle, position: number, size: number, budget: Budget) {
if (size > budget.metadataRemaining)
throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes');
const chunk = await readAt(handle, position, size);
budget.metadataRemaining -= chunk.length;
budget.scan.metadataBytesRead += chunk.length;
return chunk;
}
async function readTranscript(handle: FileHandle, size: number, budget: Budget): Promise<Buffer> {
if (size > budget.transcriptRemaining)
throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes');
const buffer = await readAt(handle, 0, size);
budget.transcriptRemaining -= buffer.length;
budget.scan.bytesRead += buffer.length;
return buffer;
}
type Identity = Omit<Source, 'identity' | 'size'>;
function metadata(value: unknown, path: string): Identity | undefined {
const entry = object(value);
if (entry.type !== 'session_meta') return undefined;
const p = object(entry.payload);
const sessionId = fullId(p.id) ?? fullId(p.session_id);
if (!sessionId) return undefined;
const spawn = object(object(object(p.source).subagent).thread_spawn);
const forkedFromId = fullId(p.forked_from_id);
// A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread.
const parentId =
fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId;
// Codex: "session_id is equal to the root thread's ID".
const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id);
const start = p.subagent_history_start_ordinal;
return {
path,
sessionId,
parentId,
forkedFromId,
rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined,
historyStart: Number.isSafeInteger(start) && (start as number) >= 0 ? (start as number) : undefined,
timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '',
};
}
function sameIdentity(a: Identity, b: Identity): boolean {
return (
a.sessionId === b.sessionId &&
a.parentId === b.parentId &&
a.forkedFromId === b.forkedFromId &&
a.rootId === b.rootId &&
a.historyStart === b.historyStart
);
}
/**
* The first `session_meta` within the file's head. Problems with the file
* itself throw {@link UnattributableFileError}; a budget throws
* {@link HistoryBudgetError}; a short file with no metadata yet returns undefined.
*/
async function readMetadata(path: string, budget: Budget): Promise<Source | undefined> {
let handle: FileHandle;
try {
handle = await regularFile(path);
} catch (error) {
throw new UnattributableFileError(error instanceof Error ? error.message : String(error));
}
try {
const before = await handle.stat();
const limit = Math.min(before.size, METADATA_HEAD_BYTES);
let buffer = Buffer.alloc(0);
let parsed = 0;
while (buffer.length < limit) {
const chunk = await readMetadataChunk(
handle,
buffer.length,
Math.min(METADATA_CHUNK_BYTES, limit - buffer.length),
budget
);
if (!chunk.length) break;
if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC))
throw new UnattributableFileError(
'History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read'
);
buffer = Buffer.concat([buffer, chunk]);
let end = buffer.indexOf(10, parsed);
while (end >= 0) {
const line = buffer.subarray(parsed, end);
parsed = end + 1;
end = buffer.indexOf(10, parsed);
if (!isUtf8(line)) throw new UnattributableFileError('History metadata contains invalid UTF8');
let value: unknown;
try {
value = JSON.parse(line.toString('utf8'));
} catch {
continue;
}
const found = metadata(value, path);
if (!found) continue;
// session_meta is written once; a growing file is a live rollout, not a change.
const after = await handle.stat();
if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size)
throw new UnattributableFileError('History source changed during metadata scan');
return { ...found, identity: fileIdentity(before), size: before.size };
}
}
if (buffer.length < limit) throw new UnattributableFileError('History source changed during metadata scan');
if (before.size > METADATA_HEAD_BYTES)
throw new UnattributableFileError('No session metadata within the first 128 KiB');
return undefined;
} finally {
await handle.close();
}
}
async function findSources(root: string, budget: Budget): Promise<Source[]> {
// The root may itself be a symlink, as the Past Sessions scanner allows
// (`codex-transcript.ts` reads it with readdir, which follows it); links
// BELOW the root are never followed by either reader.
let rootStat;
try {
rootStat = await stat(root);
} catch {
throw new Error(`Sessions root not found: ${root}`);
}
if (!rootStat.isDirectory()) throw new Error('Sessions root must be a directory');
const sources: Source[] = [];
const visit = async (dir: string, depth: number): Promise<void> => {
if (depth > MAX_DIRECTORY_DEPTH) throw new HistoryBudgetError('History directory scan depth budget exhausted');
let entries;
try {
entries = await opendir(dir);
} catch {
budget.scan.unreadableDirectories++;
return;
}
for await (const entry of entries) {
if (--budget.entriesRemaining < 0)
throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries');
budget.scan.entries++;
const path = join(dir, entry.name);
if (entry.isSymbolicLink()) {
budget.scan.skippedSymlinks++;
continue;
}
if (entry.isDirectory()) {
// Recheck immediately before descending; never intentionally follow links.
const now = await lstat(path).catch(() => undefined);
if (!now?.isDirectory()) {
budget.scan.unreadableDirectories++;
continue;
}
await visit(path, depth + 1);
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
let source: Source | undefined;
try {
source = await readMetadata(path, budget);
} catch (error) {
if (!(error instanceof UnattributableFileError)) throw error;
}
if (source) sources.push(source);
else budget.scan.unattributedFiles++;
} else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) {
// Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`),
// and that sibling is visited (or counted) on its own.
const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined);
if (plain) continue;
budget.scan.compressedFiles++;
budget.compressedPaths.push(path);
}
}
};
await visit(root, 0);
return sources;
}
interface Message {
representation: 'event' | 'modern' | 'response';
role: 'user' | 'assistant';
message: string;
phase: string | null;
timestamp: string;
}
function message(value: unknown): Message | undefined {
const entry = object(value);
const p = object(entry.payload);
if (entry.isSidechain || p.isSidechain) return undefined;
const found = codexMessageFromLine(value);
if (!found) return undefined;
// Codex context is not a human turn. Real prompts also arrive as events, which are never filtered.
if (found.injectionProne && isCodexInjectedContext(found.text)) return undefined;
return {
role: found.role,
message: found.text,
// Codex records a phase on assistant messages only; a user turn has none.
phase: found.role === 'assistant' ? (found.phase ?? null) : null,
representation: found.shape === 'item' ? 'modern' : found.shape,
timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '',
};
}
/** Native events and response_item commonly duplicate one accepted message. */
function collapse(items: Message[]): Message[] {
const records: Message[] = [];
let representations = new Set<Message['representation']>();
for (const raw of items) {
const item = { ...raw };
const previous = records.at(-1);
if (
previous?.role === item.role &&
previous.message === item.message &&
!representations.has(item.representation)
) {
representations.add(item.representation);
if (!previous.timestamp) previous.timestamp = item.timestamp;
if (item.phase === 'final_answer' || previous.phase === null) previous.phase = item.phase ?? previous.phase;
continue;
}
records.push(item);
representations = new Set([item.representation]);
}
return records;
}
function isPrefixOf(prefix: Message[], whole: Message[]): boolean {
return (
prefix.length <= whole.length &&
prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message)
);
}
interface ReadContext {
/**
* The ancestor's own messages, uncut, or undefined when its file is not in the
* store or could not be read for any reason other than a budget.
*/
ancestorMessages(id: string): Promise<Message[] | undefined>;
}
type Piece = Message | { boundary: true };
interface LegacyPrefix {
ancestorId: string;
/** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */
pieces: Piece[];
}
/** One read of one rollout: every view later needed of it, so no file is read (or charged) twice. */
interface ParsedSource {
/** Every message in file order, inherited copies included: what a child's copy of this thread holds. */
raw: Message[];
/** Messages before any legacy inherited prefix, paginated inherited records already dropped. */
own: Message[];
legacy?: LegacyPrefix;
}
async function parseSource(source: Source, budget: Budget): Promise<ParsedSource> {
const handle = await regularFile(source.path);
let buffer: Buffer;
try {
const now = await handle.stat();
// A live rollout only grows. A different file or a shorter one is a rewrite.
if (fileIdentity(now) !== source.identity || now.size < source.size)
throw new Error('History source changed after attribution; retry explicitly');
buffer = await readTranscript(handle, now.size, budget);
if (buffer.length !== now.size) throw new Error('History source shrank while reading; retry explicitly');
} finally {
await handle.close();
}
// Snapshot up to the last complete line: a running Codex may be mid-append.
const end = buffer.lastIndexOf(10) + 1;
const complete = buffer.subarray(0, end);
if (buffer.subarray(end).toString('latin1').trim()) budget.scan.partialRecords++;
if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8');
const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id));
const raw: Message[] = [];
const own: Message[] = [];
let legacy: LegacyPrefix | undefined;
let lastOrdinal = -1;
for (const line of complete.toString('utf8').split('\n')) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
budget.scan.malformedRecords++;
continue;
}
const entry = object(parsed);
const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined;
if (ordinal !== undefined) lastOrdinal = Math.max(lastOrdinal, ordinal);
if (source.historyStart !== undefined && ordinal === undefined) {
// Codex's projection skips a paginated record without an ordinal; so does this, visibly.
budget.scan.malformedRecords++;
continue;
}
const inherited = source.historyStart !== undefined && ordinal! < source.historyStart;
if (entry.type === 'session_meta') {
const declared = metadata(parsed, source.path);
if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source)) continue;
if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId))
throw new Error('History source contains conflicting native metadata; attribution refused');
// An ancestor that was itself a fork carries its own ancestors' metadata in the copy.
for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id);
if (inherited || legacy) continue;
budget.scan.inheritedPrefixes++;
legacy = { ancestorId: declared.sessionId, pieces: [] };
continue;
}
if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') {
if (!inherited) legacy.pieces.push({ boundary: true });
continue;
}
const item = message(parsed);
if (!item) continue;
raw.push(item);
if (inherited) continue;
if (legacy) legacy.pieces.push(item);
else own.push(item);
}
if (source.historyStart !== undefined && source.historyStart > 0) {
budget.scan.inheritedPrefixes++;
// Initialization died while copying the prefix: Codex refuses to resume such a file.
if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++;
}
return { raw: collapse(raw), own, legacy };
}
/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */
async function readMessages(parsed: ParsedSource, budget: Budget, context: ReadContext): Promise<Message[]> {
const own = [...parsed.own];
if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context)));
return collapse(own);
}
/** The child's own messages after a legacy inherited prefix. */
async function afterLegacyPrefix(prefix: LegacyPrefix, budget: Budget, context: ReadContext): Promise<Message[]> {
const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : []));
if (!boundaries.length) {
budget.scan.incompletePrefixes++;
return [];
}
const messagesBefore = (index: number) =>
collapse(prefix.pieces.slice(0, index).filter((piece): piece is Message => !('boundary' in piece)));
let cut = boundaries[0];
if (boundaries.length > 1) {
// The last candidate whose preceding messages the parent's own file also holds.
const parent = await context.ancestorMessages(prefix.ancestorId);
const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : [];
if (proven.length) cut = proven.at(-1)!;
else {
// Unprovable: drop up to the last candidate, so parent text is never attributed to the child.
budget.scan.unverifiedPrefixes++;
cut = boundaries.at(-1)!;
}
}
return prefix.pieces.slice(cut + 1).filter((piece): piece is Message => !('boundary' in piece));
}
function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] {
const turns: Message[][] = [];
for (const item of messages) {
if (item.role === 'user' || !turns.length) turns.push([]);
turns.at(-1)!.push(item);
}
const needle = options.topic?.trim().toLocaleLowerCase();
return turns
.filter((turn) => {
const dateMatches =
(!options.after && !options.before) ||
turn.some((item) => {
const date = utcDate(item.timestamp);
return (
date !== undefined &&
(!options.after || date >= options.after) &&
(!options.before || date <= options.before)
);
});
return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle)));
})
.flat();
}
/** Native ancestry is metadata evidence, not the conversation selected by a query. */
function nativeRoot(source: Source, sources: Source[]): string | null {
const seen = new Set<string>();
let current = source;
while (true) {
if (seen.has(current.sessionId)) throw new Error('Native history contains cyclic ancestry');
seen.add(current.sessionId);
if (current.rootId) return current.rootId;
if (!current.parentId) return current.sessionId;
const parents = sources.filter((candidate) => candidate.sessionId === current.parentId);
if (parents.length === 0) return null;
if (parents.length > 1) throw new Error('Native ancestor id is ambiguous');
current = parents[0];
}
}
/**
* The not-found error, naming what the store held but could not attribute.
* Codex names a rollout `rollout-<timestamp>-<thread id>.jsonl[.zst]`, so a
* compressed file whose name carries the wanted id is named outright; the name
* is only a hint here, never identity.
*/
function missingSessionMessage(wanted: string, budget: Budget): string {
const { scan } = budget;
const notes: string[] = [];
if (scan.compressedFiles) {
// `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id.
const named = budget.compressedPaths.filter((path) => {
const name = basename(path).toLowerCase();
return name.startsWith('rollout-') && name.slice(28).startsWith(wanted);
});
const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : '');
notes.push(
named.length
? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc <path> > copy.jsonl)`
: `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read`
);
}
if (scan.unattributedFiles) notes.push(`${scan.unattributedFiles} file(s) could not be attributed`);
return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`;
}
export async function readLocalHistory(options: LocalHistoryOptions): Promise<LocalHistoryResult> {
validate(options);
const scan: LocalHistoryResult['scan'] = {
bytesRead: 0,
metadataBytesRead: 0,
entries: 0,
malformedRecords: 0,
partialRecords: 0,
skippedSymlinks: 0,
unattributedFiles: 0,
compressedFiles: 0,
unreadableDirectories: 0,
inheritedPrefixes: 0,
incompletePrefixes: 0,
unverifiedPrefixes: 0,
};
const budget: Budget = {
transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES,
metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES,
entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES,
scan,
compressedPaths: [],
};
let sources: Source[];
let attributionSources: Source[];
if (options.file !== undefined) {
let source: Source | undefined;
try {
source = await readMetadata(resolve(options.file), budget);
} catch (error) {
throw error instanceof UnattributableFileError ? new Error(error.message) : error;
}
if (!source) throw new Error('History source is missing valid native session metadata');
sources = [source];
attributionSources = sources;
} else {
const root = options.sessionsRoot ?? codexSessionsRoot();
const all = await findSources(resolve(root), budget);
const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase();
const matches = all.filter((source) => source.sessionId.startsWith(wanted));
if (!matches.length) throw new Error(missingSessionMessage(wanted, budget));
if (matches.length !== 1) throw new Error('Native session id is ambiguous');
const target = matches[0];
attributionSources = all;
const queryRootId = target.sessionId;
sources = [target];
if (options.rootSessionId) {
const selected = new Set([target.sessionId]);
let changed = true;
while (changed) {
changed = false;
for (const source of all) {
if (
!selected.has(source.sessionId) &&
(source.rootId === queryRootId || (source.parentId && selected.has(source.parentId)))
) {
selected.add(source.sessionId);
sources.push(source);
changed = true;
}
}
}
for (const source of sources) {
if (all.filter((other) => other.sessionId === source.sessionId).length !== 1)
throw new Error('Native family contains ambiguous duplicated session ids');
}
}
}
sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId));
const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)]));
// Each selected file is read once, whether the main loop or a child's boundary proof asks first.
const parsedCache = new Map<Source, Promise<ParsedSource>>();
const parse = (source: Source) => {
let parsed = parsedCache.get(source);
if (!parsed) {
parsed = parseSource(source, budget);
parsedCache.set(source, parsed);
}
return parsed;
};
const context: ReadContext = {
// Raw messages need no boundary proof of their own, so this never recurses.
async ancestorMessages(id) {
const candidates = attributionSources.filter((source) => source.sessionId === id);
if (candidates.length !== 1) return undefined;
try {
return (await parse(candidates[0])).raw;
} catch (error) {
// A budget always fails the read; any other failure only leaves the boundary unproven.
if (error instanceof HistoryBudgetError) throw error;
return undefined;
}
},
};
const records: HistoryRecord[] = [];
for (const source of sources) {
records.push(
...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({
rootSessionId: roots.get(source.sessionId) ?? null,
sessionId: source.sessionId,
role: item.role,
message: item.message,
phase: item.phase,
timestamp: item.timestamp,
}))
);
}
if (!records.length) throw new Error('No matching user/assistant evidence found');
return {
source: 'codex-jsonl',
complete:
scan.malformedRecords === 0 &&
scan.partialRecords === 0 &&
scan.skippedSymlinks === 0 &&
scan.unattributedFiles === 0 &&
scan.compressedFiles === 0 &&
scan.unreadableDirectories === 0 &&
scan.incompletePrefixes === 0 &&
scan.unverifiedPrefixes === 0 &&
[...roots.values()].every((root) => root !== null),
sessions: sources.map((source) => ({
sessionId: source.sessionId,
rootSessionId: roots.get(source.sessionId) ?? null,
...(source.parentId ? { parentSessionId: source.parentId } : {}),
path: source.path,
})),
records,
scan,
};
}
/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */
export function validateOutputBytes(maxBytes: number): void {
checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes');
}
/** Output never splits a JSON document, UTF8 character, record, or selected turn. */
export function formatLocalHistory(
result: LocalHistoryResult,
format: 'json' | 'text' = 'json',
maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES
): string {
validateOutputBytes(maxBytes);
const body =
format === 'json'
? JSON.stringify(result)
: [
`source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`,
...result.records.map(
(row) =>
`[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}`
),
].join('\n\n');
if (Buffer.byteLength(body) + 1 > maxBytes)
throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes');
return body;
}
+2 -1
View File
@@ -38,6 +38,7 @@ import {
buildSendBody,
composerReadyMark,
describeFailure,
describeRequestError,
httpRequest,
inputRefusal,
isSelfSession,
@@ -497,7 +498,7 @@ export function createToolRunner(deps: ToolRunnerDeps) {
} catch (err) {
if (signal?.aborted) throw err;
throw new ToolError(
`Cannot reach Codeman at ${ctx.apiUrl}: ${getErrorMessage(err)}. Check that it is running. If every call ` +
`Cannot reach Codeman at ${ctx.apiUrl}: ${describeRequestError(err)}. Check that it is running. If every call ` +
'fails at once, a sandbox around this MCP server may be blocking network access to this machine.'
);
}
+3 -2
View File
@@ -314,9 +314,10 @@ export interface TerminalMultiplexer extends EventEmitter {
// ========== Input ==========
/**
* Send input to a session via tmux send-keys.
* Send input to a session via tmux send-keys. The optional internal Enter-only
* guard runs synchronously immediately before transport dispatch.
*/
sendInput(sessionId: string, input: string): Promise<boolean>;
sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean>;
// ========== Metadata ==========
+5 -3
View File
@@ -338,9 +338,11 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
try {
settings = await this.readSettings();
} catch (err) {
// A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
// than once a minute per session, and only a session that could nudge retries.
// A failed read is retried (settings.json is replaced atomically, so a read never sees a
// half-written file; a failure is a read error or a file that does not parse). A settings.json
// that stays broken (a hand edit with a trailing comma) fails every read until it is fixed, so
// it is logged once rather than once a minute per session, and only a session that could
// nudge retries.
const message = err instanceof Error ? err.message : String(err);
if (message !== this.settingsFailure) {
this.settingsFailure = message;
+20 -3
View File
@@ -89,6 +89,19 @@ export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
}
/**
* Remote tmux runs in sshd's non-interactive shell, which may omit Homebrew and
* per-user binaries. Keep every remote tmux operation on the same PATH. Quoting
* the entire assignment preserves spaces in HOME/inherited PATH under dash too.
* CLI commands still use their existing login-shell/configuration handling.
*/
export function withRemoteTmuxPath(command: string): string {
return (
'export PATH="$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; ' +
command
);
}
/**
* The CLI text a location overlay should launch for `mode`, or null when this build has no
* entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare
@@ -228,7 +241,11 @@ export function buildRemoteTmuxCheckCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): string {
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
return [
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(withRemoteTmuxPath('command -v tmux')),
].join(' ');
}
export interface RemoteTmuxCheckResult {
@@ -368,7 +385,7 @@ export function buildRemoteSessionAliveCommand(
): string {
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`;
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' ');
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd))].join(' ');
}
/**
@@ -453,7 +470,7 @@ export function buildRemoteListSessionsCommand(
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
const remoteCmd =
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
parts.push(remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd)));
return parts.join(' ');
}
+8 -2
View File
@@ -48,7 +48,13 @@ import { exec } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { promisify } from 'node:util';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
import {
buildSshConnectionArgs,
classifyRemoteAliveExit,
remoteSshTarget,
shellescape,
withRemoteTmuxPath,
} from './remote-hosts.js';
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { remoteTmuxLocation } from './tmux-manager.js';
@@ -153,7 +159,7 @@ export function buildRemoteScrollbackCommand(
marker: string
): string {
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
const script = withRemoteTmuxPath(buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker));
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
}
+364 -15
View File
@@ -68,6 +68,7 @@ import {
type StatusLineExporter,
type KeepWarmStatus,
} from './types.js';
import type { GitFetchStatus } from './git-fetch.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
@@ -129,6 +130,14 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { getCli } from './config/cli-registry/registry.js';
import { SubmitVerifier } from './session-submit-verifier.js';
import {
guardedComposerChrome,
promptSha256,
readGuardedSnapshotDetails,
type GuardedInputSnapshot,
} from './agent-staging.js';
import { isKeyboardTerminalInput } from './terminal-input-classifier.js';
import { stripAnsi } from './utils/regex-patterns.js';
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
import { compileVersionRegex } from './config/cli-registry/patterns.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
@@ -151,6 +160,7 @@ import {
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
import { resolveConfigModel } from './model-config-resolvers.js';
import { caseSessionSettled, isCasePathLocked, noteCaseSessionStarting } from './case-rename.js';
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
import {
launchModelFor,
@@ -456,6 +466,17 @@ export interface ClaudeMessage {
duration_ms?: number;
}
/**
* A write that a guarded submit's lease could not hold, because the per-lease hold
* queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it.
*/
export class GuardedInputBusyError extends Error {
constructor() {
super('A guarded submit is in progress on this session; nothing was written. Retry the input.');
this.name = 'GuardedInputBusyError';
}
}
/**
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
@@ -613,6 +634,12 @@ export class Session extends EventEmitter {
private _keepWarmEnabled: boolean | undefined;
/** Last status the keep-warm service published for this session; ephemeral. */
private _keepWarm: KeepWarmStatus | null = null;
/** Git fetch on an interval for this session; undefined = follow the app-wide default. Persisted. */
private _gitFetchEnabled: boolean | undefined;
/** Seconds between this session's fetch runs; undefined = follow the app-wide default. Persisted. */
private _gitFetchIntervalSeconds: number | undefined;
/** Last status the fetch scheduler published for this session; ephemeral. */
private _gitFetch: GitFetchStatus | null = null;
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
private _keepAliveTurnUntil = 0;
/** Last keystroke a person sent through a `fromUser` write. */
@@ -925,6 +952,10 @@ export class Session extends EventEmitter {
lastRealPromptAt?: number;
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
keepWarmEnabled?: boolean;
/** Restored Git fetch override for this session (see `gitFetchEnabled`). */
gitFetchEnabled?: boolean;
/** Restored Git fetch interval for this session (see `gitFetchIntervalSeconds`). */
gitFetchIntervalSeconds?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
@@ -948,6 +979,15 @@ export class Session extends EventEmitter {
}
) {
super();
// A case folder being renamed (src/case-rename.ts) must not gain a session mid-move: its pane would
// start in a directory that is about to have a different path.
if (config.workingDir && isCasePathLocked(config.workingDir)) {
throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`);
}
// And a rename must not start until this session is in the server's map, where it can be listed and
// stopped: until registerSessionWithLayout() (or stop()) settles it, the rename route refuses the move.
// A remote session's workingDir is the remote path, which the route never counts as the folder's.
if (config.workingDir && !config.remote) noteCaseSessionStarting(this, config.workingDir);
this.setMaxListeners(25);
// Default error handler prevents unhandled 'error' events from crashing the process.
@@ -1005,6 +1045,10 @@ export class Session extends EventEmitter {
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0;
this._keepWarmEnabled = config.keepWarmEnabled;
// Restored, like the keep-warm choice: the interval is the user's decision about their repositories
// and outlives the run that made it, so a restart keeps it rather than falling back to the app default.
this._gitFetchEnabled = config.gitFetchEnabled;
this._gitFetchIntervalSeconds = config.gitFetchIntervalSeconds;
// Restored chain: its tail is the conversation the CLI was actually on when
// the server stopped, which outranks the launch id seeded just above. The
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
@@ -1298,6 +1342,7 @@ export class Session extends EventEmitter {
/** Set the owning username (used by recovery to restore ownership). */
set owner(username: string | undefined) {
if (this._owner !== username) this._guardedPaneRevision++;
this._owner = username;
}
@@ -1402,11 +1447,13 @@ export class Session extends EventEmitter {
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
*/
markClosing(closing: boolean): void {
if (this._closing !== closing) this._guardedPaneRevision++;
this._closing = closing;
}
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
this._guardedPaneRevision++;
this._paneLifecycleOps++;
try {
return await op();
@@ -1433,6 +1480,7 @@ export class Session extends EventEmitter {
* Ark0N/Codeman#446 review; both now persist on their success path.
*/
private clearPaneExitForNewPane(): void {
this._guardedPaneRevision++;
this.setPaneExit(undefined);
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
@@ -1569,6 +1617,37 @@ export class Session extends EventEmitter {
return changed;
}
/** Git fetch override for this session; undefined means the app-wide setting applies. */
get gitFetchEnabled(): boolean | undefined {
return this._gitFetchEnabled;
}
/** Interval override for this session; undefined means the app-wide setting applies. */
get gitFetchIntervalSeconds(): number | undefined {
return this._gitFetchIntervalSeconds;
}
/**
* Set the session's override. The Session Options rows save on their own, so a caller passes what it has
* and leaves the other half as it was; `undefined` on either means the app-wide setting applies again.
*/
setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
this._gitFetchEnabled = enabled;
this._gitFetchIntervalSeconds = intervalSeconds;
}
/** What the fetch scheduler last reported for this session, or null before its first run. */
get gitFetch(): GitFetchStatus | null {
return this._gitFetch;
}
/** Replace the fetch status; true when it differs from the previous one. */
setGitFetchStatus(status: GitFetchStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this._gitFetch);
this._gitFetch = status;
return changed;
}
/**
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
@@ -2106,6 +2185,9 @@ export class Session extends EventEmitter {
statusLineExporter: this.statusLineExporter,
keepWarmEnabled: this._keepWarmEnabled,
keepWarm: this._keepWarm ?? undefined,
gitFetchEnabled: this._gitFetchEnabled,
gitFetchIntervalSeconds: this._gitFetchIntervalSeconds,
gitFetch: this._gitFetch ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
@@ -4196,6 +4278,11 @@ export class Session extends EventEmitter {
prompt: string,
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
): Promise<{ result: string; cost: number }> {
if (this.guardedInputHeld) {
this._guardedInputRevision++;
throw new Error('Guarded submission in progress');
}
this._guardedPaneRevision++;
return new Promise((resolve, reject) => {
if (this.ptyProcess) {
reject(new Error('Session already has a running process'));
@@ -4705,6 +4792,229 @@ export class Session extends EventEmitter {
// Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps)
private readonly _inspectionInstance = uuidv4();
private _guardedPaneRevision = 0;
private _guardedGeometryRevision = 0;
private _guardedInputRevision = 0;
private _guardedPendingWrites = 0;
private _guardedSubmitInFlight = false;
/**
* Input that arrived while a guarded submit held the lease, in arrival order. Each
* entry runs when the lease is released; an entry that returns a promise (a mux
* write, a named key) is waited for before the next runs, so nothing overtakes it.
*/
private _guardedHeld: Array<{ run: () => void | Promise<unknown>; bytes: number }> = [];
private _guardedHeldBytes = 0;
/** True while the drain waits for a held asynchronous write to finish. */
private _guardedHeldDraining = false;
/** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */
static readonly MAX_GUARDED_HELD_INPUTS = 256;
/** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */
static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024;
/** Ephemeral identity, never persisted or based on a potentially reused pid alone. */
get inspectionGeneration(): string {
return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`;
}
/** Staging additionally binds geometry; transcript cursors survive a terminal resize. */
get guardedInputGeneration(): string {
return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`;
}
get guardedInputPendingWrites(): number {
return this._guardedPendingWrites;
}
/**
* True while a guarded submit holds the input lease (a few milliseconds). Input that
* arrives in that span is held and written after it (see {@link guardedInputHeld}).
*/
get guardedSubmitInFlight(): boolean {
return this._guardedSubmitInFlight;
}
/**
* True while input is being held behind a guarded submit: the lease itself, or held
* input not yet written after it. A write that is not routed through
* {@link holdForGuardedLease} must refuse in this window, or it overtakes held input.
* `write()` is the known exception: it refuses during the lease only, since the drain
* writes held input through it, so a direct `write()` caller (cron's `paste` mode in
* `cron/cron-service.ts`) can still land ahead of input that is draining.
*/
get guardedInputHeld(): boolean {
return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining;
}
/**
* Hold input behind a guarded submit's lease instead of refusing it.
*
* `free`: nothing is held, so the caller writes inline as it always did. `held`: `run`
* is queued and runs, in arrival order with every other held input, once the lease is
* released (whatever the submit's outcome). `full`: the per-lease cap is reached and
* nothing was queued; the caller must stop delivering from that source until it can
* resend in order. Refusing instead lost input: the browser's WebSocket path does not
* wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then
* rejected as a duplicate. `data`, when given, is observed as input on arrival, so a
* keystroke held during the lease still moves the input revision at once.
*/
holdForGuardedLease(run: () => void | Promise<unknown>, bytes = 0, data?: string): 'free' | 'held' | 'full' {
if (!this.guardedInputHeld) return 'free';
if (data !== undefined) this._observeGuardedKeyboardInput(data);
if (
this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS ||
this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES
)
return 'full';
this._guardedHeld.push({ run, bytes });
this._guardedHeldBytes += bytes;
return 'held';
}
/**
* Run an asynchronous write now, or in its turn behind a guarded submit's lease. The
* turn lasts until the write settles, so later held input waits for it. Resolves
* `whenFull` without running anything when the hold queue is full.
*/
private _inGuardedTurn<T>(write: () => Promise<T>, whenFull: () => T): Promise<T> {
if (!this.guardedInputHeld) return write();
return new Promise<T>((resolve, reject) => {
const held = this.holdForGuardedLease(() => {
const running = write();
running.then(resolve, reject);
return running;
});
if (held === 'full') {
try {
resolve(whenFull());
} catch (err) {
reject(err);
}
}
});
}
/** Run held input in arrival order until it is all written or a new lease is taken. */
private _drainGuardedHeld(): void {
while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) {
const entry = this._guardedHeld.shift()!;
this._guardedHeldBytes -= entry.bytes;
let running: void | Promise<unknown>;
try {
running = entry.run();
} catch (err) {
console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err));
continue;
}
if (running && typeof running.then === 'function') {
this._guardedHeldDraining = true;
void running
.catch(() => {})
.then(() => {
this._guardedHeldDraining = false;
this._drainGuardedHeld();
});
}
}
}
/** Expose only the path selection needed for attributed Codex evidence, never credentials. */
getCodexHomeOverride(): string | undefined {
return this._envOverrides?.CODEX_HOME || undefined;
}
private _observeGuardedKeyboardInput(data: string): void {
if (data && isKeyboardTerminalInput(data)) this._guardedInputRevision++;
}
guardedInputSnapshot(): GuardedInputSnapshot | null {
return this._readGuardedInputSnapshot(false);
}
private _readGuardedInputSnapshot(allowLease: boolean): GuardedInputSnapshot | null {
if (
this._isStopped ||
this._closing ||
this.paneLifecycleInFlight ||
this._guardedPendingWrites ||
(!allowLease && this.guardedInputHeld) ||
this.remote ||
this.docker ||
this.paneExit ||
!this._muxSession
)
return null;
// Only a CLI whose registry entry describes its composer chrome takes guarded input.
const chrome = guardedComposerChrome(getCli(this.mode));
if (!chrome) return null;
const capture: import('./mux-interface.js').PaneCaptureOptions = {};
const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture);
if (!screen) return null;
// tmux's replay has CUP addresses rather than line breaks. Read the SAME
// fresh frame for dialog/work chrome and exact composer/caret proof.
// eslint-disable-next-line no-control-regex
const plain = stripAnsi(screen.replace(/\x1b\[\d+;\d+H/g, '\n'));
const working = this._workingLinePattern();
working.lastIndex = 0;
if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null;
const composer = readGuardedSnapshotDetails(screen, chrome);
return composer === null
? null
: {
generation: this.guardedInputGeneration,
revision: this._guardedInputRevision,
composerSha: promptSha256(composer.composer),
availableColumns: Number.isFinite(capture.capturedGeometry?.cols)
? capture.capturedGeometry!.cols - composer.prefixWidth
: 0,
};
}
/** Stage through ordinary delivery, after cancelling any older Enter retry timer. */
async stageGuardedInput(message: string): Promise<boolean> {
if (/[\r\n]/.test(message)) return false;
if (this._guardedPendingWrites || this.guardedInputHeld) {
this._guardedInputRevision++;
return false;
}
this._submitVerifier?.cancel();
return this.writeViaMux(message, { fromUser: true });
}
/** One conditional Enter. An ambiguous acknowledgement is never replayed. */
async submitGuardedInput(expected: GuardedInputSnapshot): Promise<boolean> {
const matches = (current: GuardedInputSnapshot | null): boolean =>
!!current &&
current.generation === expected.generation &&
current.revision === expected.revision &&
current.composerSha === expected.composerSha &&
current.availableColumns === expected.availableColumns;
if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false;
this._guardedSubmitInFlight = true;
let dispatched = false;
try {
const sent = await this._mux.sendInput(this.id, '\r', {
beforeDispatch: () => {
if (!matches(this._readGuardedInputSnapshot(true))) return false;
this._guardedInputRevision++;
dispatched = true;
return true;
},
});
if (sent && dispatched) this._emitSubmittedPrompt(this._trackSubmit('\r', { fromUser: true }));
return sent && dispatched;
} finally {
this._guardedSubmitInFlight = false;
this._drainGuardedHeld();
}
}
/**
* Lease/pending boundary for named keys whose bytes bypass the ordinary write methods.
* During a guarded submit the operation waits for the lease like any other write;
* only a full hold queue refuses it, with {@link GuardedInputBusyError}.
*/
async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> {
this._guardedInputRevision++;
return this._inGuardedTurn(
async () => {
this._guardedPendingWrites++;
try {
await operation();
} finally {
this._guardedPendingWrites--;
}
},
() => {
throw new GuardedInputBusyError();
}
);
}
/**
* Sends input directly to the PTY process.
*
@@ -4724,6 +5034,9 @@ export class Session extends EventEmitter {
* input could disappear while the caller believed it had been delivered.
*/
write(data: string, options: SessionWriteOptions = {}): boolean {
this._observeGuardedKeyboardInput(data);
// The lease only, not the drain, which writes held input through here (see guardedInputHeld).
if (this._guardedSubmitInFlight) return false;
const submittedPrompt = this._trackSubmit(data, options);
if (!this.ptyProcess) return false;
this.ptyProcess.write(data);
@@ -4772,6 +5085,7 @@ export class Session extends EventEmitter {
* separator. Reports submissions like a write would (a line feed never is one).
*/
trackUserInput(data: string): void {
this._observeGuardedKeyboardInput(data);
if (!this._acceptsPrompts) return;
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
}
@@ -4793,6 +5107,7 @@ export class Session extends EventEmitter {
* their prompt must count as real and alert normally.
*/
markPromptSubmitted(prompt?: string): void {
this._guardedInputRevision++;
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
this._stampSubmit();
}
@@ -4888,22 +5203,39 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
const submittedPrompt = this._trackSubmit(data, options);
if (this._mux && this._muxSession) {
const sent = await this._mux.sendInput(this.id, data);
if (sent) {
this._emitSubmittedPrompt(submittedPrompt);
this._verifySubmitted(data);
this._observeGuardedKeyboardInput(data);
// A guarded submit's lease lasts for its Enter only: wait for it, in arrival order,
// rather than refusing, so an approval answer or a programmatic prompt is not
// dropped. Without a lease the write starts synchronously, so the pending count
// rises before any await and no lease can be taken while it is in flight.
return this._inGuardedTurn(
() => this._writeViaMuxNow(data, options),
() => false
);
}
private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise<boolean> {
this._guardedPendingWrites++;
try {
const submittedPrompt = this._trackSubmit(data, options);
if (this._mux && this._muxSession) {
const sent = await this._mux.sendInput(this.id, data);
if (sent) {
this._emitSubmittedPrompt(submittedPrompt);
this._verifySubmitted(data);
}
return sent;
}
return sent;
// Fallback to PTY write
if (this.ptyProcess) {
this.ptyProcess.write(data);
this._emitSubmittedPrompt(submittedPrompt);
return true;
}
return false;
} finally {
this._guardedPendingWrites--;
}
// Fallback to PTY write
if (this.ptyProcess) {
this.ptyProcess.write(data);
this._emitSubmittedPrompt(submittedPrompt);
return true;
}
return false;
}
/**
@@ -4931,7 +5263,16 @@ export class Session extends EventEmitter {
this._isStopped || !this._mux || !this._muxSession
? null
: this._mux.capturePaneText?.(this._muxSession.muxName),
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
sendEnter: () => {
// A retry Enter is never held behind a guarded submit: once that submit's
// Enter went out, a re-press would submit whatever is on the composer next.
if (this.guardedInputHeld) {
this._guardedInputRevision++;
this._submitVerifier?.cancel();
return Promise.resolve(false);
}
return this.writeViaMux('\r');
},
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
// Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
@@ -4960,6 +5301,7 @@ export class Session extends EventEmitter {
* `resize()` skipped a real resize that happened to match them.
*/
private _notePtySpawnGeometry(cols: number, rows: number): void {
this._guardedPaneRevision++;
this._ptyCols = cols;
this._ptyRows = rows;
}
@@ -5082,6 +5424,7 @@ export class Session extends EventEmitter {
}
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
this._guardedGeometryRevision++;
this._ptyCols = cols;
this._ptyRows = rows;
if (!IS_TEST_MODE && this._mux && this._muxSession) {
@@ -5098,6 +5441,10 @@ export class Session extends EventEmitter {
// Legacy method for sending input - wraps runPrompt
async sendInput(input: string): Promise<void> {
if (this.guardedInputHeld) {
this._guardedInputRevision++;
throw new Error('Guarded submission in progress');
}
this._status = 'busy';
this._markActivity(true);
this.runPrompt(input).catch((err) => {
@@ -5175,6 +5522,8 @@ export class Session extends EventEmitter {
async stop(killMux: boolean = true): Promise<void> {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// A session stopped before it was registered (a create that failed) no longer holds up a case rename.
caseSessionSettled(this);
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
// without this the last chip it drew would ride along on its row forever.
this._watching = null;
+62
View File
@@ -0,0 +1,62 @@
/** @fileoverview Distinguish complete terminal focus/mouse reports from keyboard-bearing input. */
const FOCUS_REPORT_LENGTH = 3;
const X10_REPORT_LENGTH = 6;
function sgrMouseReportLength(rest: string): number {
if (!rest.startsWith('\x1b[<')) return 0;
let offset = 3;
for (let field = 0; field < 3; field += 1) {
const digitStart = offset;
while (offset < rest.length && rest.charCodeAt(offset) >= 48 && rest.charCodeAt(offset) <= 57) {
offset += 1;
}
if (offset === digitStart) return 0;
if (field < 2) {
if (rest[offset] !== ';') return 0;
offset += 1;
}
}
return rest[offset] === 'M' || rest[offset] === 'm' ? offset + 1 : 0;
}
function x10MouseReportLength(rest: string): number {
if (!rest.startsWith('\x1b[M') || rest.length < X10_REPORT_LENGTH) return 0;
const button = rest.charCodeAt(3);
const x = rest.charCodeAt(4);
const y = rest.charCodeAt(5);
if (button < 32 || button > 255 || x < 33 || x > 255 || y < 33 || y > 255) return 0;
return X10_REPORT_LENGTH;
}
function terminalReportLengthAt(data: string, offset: number): number {
const rest = data.slice(offset);
if (rest.startsWith('\x1b[I') || rest.startsWith('\x1b[O')) return FOCUS_REPORT_LENGTH;
const sgrLength = sgrMouseReportLength(rest);
if (sgrLength > 0) return sgrLength;
return x10MouseReportLength(rest);
}
/**
* Whether terminal input contains any keyboard-bearing bytes.
*
* Returns false only when every byte is consumed as an allowlisted mouse or
* focus report. Unknown, malformed, mixed, and empty input fails closed.
*/
export function isKeyboardTerminalInput(data: string): boolean {
if (data.length === 0) return true;
let offset = 0;
while (offset < data.length) {
const reportLength = terminalReportLengthAt(data, offset);
if (reportLength === 0) return true;
offset += reportLength;
}
return false;
}
+103 -23
View File
@@ -84,6 +84,7 @@ import {
remoteLoginShellCommand,
remoteSshTarget,
remoteTmuxSessionAlive,
withRemoteTmuxPath,
} from './remote-hosts.js';
import {
buildDockerBaseArgs,
@@ -128,7 +129,7 @@ import {
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
import { restrictTmuxPrefixTable } from './tmux-prefix-table.js';
import { NESTED_TMUX_PREFIX, nestedPrefixTableResetCommands, restrictTmuxPrefixTable } from './tmux-prefix-table.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -479,9 +480,11 @@ export interface PasteTextCommands {
/**
* Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;`
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l -- 'a;'` types `a`, a lone `;`
* is no argument at all); a final `\;` is its escape for a literal `;`. A `;`
* anywhere else in the argument is literal already. Exported for unit testing.
* anywhere else in the argument is literal already. Every `send-keys -l` passes
* `--` before the text, like `set-buffer`, or tmux parses a text that starts with
* `-` as its own options. Exported for unit testing.
*/
export function escapeTmuxTrailingSemicolon(text: string): string {
return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
@@ -981,6 +984,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string {
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
}
/**
* Split the output of `capture-pane -p \; display-message -p '<cursor format>'`
* into the captured rows and the cursor line, which tmux prints last. Null unless
* that last line has the cursor query's shape (four leading integers), so a query
* that printed nothing can never have the frame's own last row taken for it.
*/
export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null {
const body = output.endsWith('\n') ? output.slice(0, -1) : output;
const split = body.lastIndexOf('\n');
const cursor = body.slice(split + 1);
if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null;
return { frame: body.slice(0, split + 1), cursor };
}
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
@@ -1229,7 +1246,9 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0'
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
@@ -1343,7 +1362,7 @@ export function buildRemoteLaunchCommand(options: {
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
'set -s escape-time 0',
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
// to the SMALLEST attached client, so two Codemans at different viewports
@@ -1352,6 +1371,15 @@ export function buildRemoteLaunchCommand(options: {
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
// remote tmux server's other sessions keep their own sizing behavior.
`set -t ${remoteName} window-size latest`,
// The prefix TABLE is server-global (unlike `prefix C-q`, which is per
// session). This socket is dedicated to Codeman's owned sessions, so the
// same reduction as the local socket applies: Ctrl+Q then )/(/s/w/L must
// not switch-client / choose-tree onto a sibling. Bind, then unbind-all,
// then bind again: a user config that left the prefix table empty makes
// `unbind-key -a -T prefix` fail and abort the rest of this chain.
// BEFORE remain-on-exit, which stays last because an old remote tmux
// rejects `failed` and aborts the rest of the chain.
...nestedPrefixTableResetCommands(),
// #210: keep a CRASHED pane so the failure is still on screen. Without this,
// tmux destroys the pane -> window -> session (and, being the only session,
// the whole remote server) the instant the pane command exits, which tears the
@@ -1368,7 +1396,8 @@ export function buildRemoteLaunchCommand(options: {
// LAST in the chain on purpose: tmux aborts the remaining commands of a `\;`
// sequence once one errors (also verified), and `failed` needs tmux >= 3.2 on
// the REMOTE host. Trailing, a rejection costs only this option; leading, it
// would silently drop status/mouse/prefix/escape-time/window-size with it.
// would silently drop status/mouse/prefix/escape-time/window-size and the
// prefix-table reduction with it.
`set -t ${remoteName} remain-on-exit failed`,
].join(' \\; ');
@@ -1381,7 +1410,14 @@ export function buildRemoteLaunchCommand(options: {
// `ssh -o BatchMode=yes` (preserving the historical token order), then the rest
// of the connection args, then the target and the quoted tmux invocation.
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
const sshParts = [
ssh,
batchMode,
'-t',
...connectionArgs,
remoteSshTarget(remote),
shellescape(withRemoteTmuxPath(tmuxInvocation)),
];
return sshParts.join(' ');
}
@@ -1398,7 +1434,7 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
const remoteName = remoteTmuxSessionName(sessionId);
const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`;
const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(withRemoteTmuxPath(killCmd))].join(' ');
}
// ========== Docker cases (COD-Docker) ==========
@@ -1540,8 +1576,12 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
`set -t ${dkrName} status off`,
`set -t ${dkrName} mouse off`,
`set -t ${dkrName} prefix C-q`,
`set -t ${dkrName} prefix ${NESTED_TMUX_PREFIX}`,
'set -s escape-time 0',
// Same reduction as the owned-remote chain: the in-container server is
// Codeman's (`-L codeman-docker`), and its stock prefix table would move
// the inner client to a sibling session on Ctrl+Q then )/(/s/w/L.
...nestedPrefixTableResetCommands(),
].join(' \\; ');
const execEnvFlags: string[] = [];
@@ -1777,7 +1817,14 @@ export function resolveDockerLaunchOptions(
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
const sshParts = [
ssh,
batchMode,
'-t',
...connectionArgs,
remoteSshTarget(remote),
shellescape(withRemoteTmuxPath(tmuxInvocation)),
];
return sshParts.join(' ');
}
@@ -3950,7 +3997,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
async sendInput(sessionId: string, input: string): Promise<boolean> {
async sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean> {
if (options?.beforeDispatch && input !== '\r') return false;
const session = this.sessions.get(sessionId);
if (!session) {
console.error(
@@ -3963,7 +4011,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// inputRunner() so a test subclass can record what WOULD reach tmux.
const run = this.inputRunner();
if (!run) {
return true;
return options?.beforeDispatch?.() ?? true;
}
console.log(
@@ -3997,7 +4045,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await run(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else {
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
@@ -4014,10 +4062,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else if (textPart) {
// Text only, no Enter
await run(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else if (hasCarriageReturn) {
// Enter only
// Enter only: no await between the guard and dispatch.
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
}
@@ -4168,7 +4217,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (textPart && hasCarriageReturn) {
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
`${tmux} send-keys -t ${shellescape(target)} -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
@@ -4180,7 +4229,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
} else if (textPart) {
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
`${tmux} send-keys -t ${shellescape(target)} -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
@@ -4246,7 +4295,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
if (IS_TEST_MODE) return '';
const exec = this.captureRunner();
if (!exec) return '';
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) {
console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget });
@@ -4284,9 +4334,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`;
const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`;
// The visible frame and its caret come from ONE tmux invocation
// (the capture, `\;`, then the cursor query): tmux runs a command list back to back
// in its command queue, with no pane output parsed between the two, so the
// caret belongs to the frame. Two separate calls raced the CLI's repaint: a
// CLI that redraws its composer a few ms after input arrives (codex) could be
// captured before the redraw and its caret queried after it, and guarded
// stage then refused a frame the keys had already reached. Only output that
// splitFrameAndCursor() cannot split (a last line that is not the query's)
// falls back to the two separate calls, which is what this always did. A call
// that THREW (a hung tmux timing out) goes to the catch below like any other
// capture failure: retrying it would block the event loop for a second timeout
// on every `GET .../terminal` load.
const combined = fullHistory
? null
: splitFrameAndCursor(
exec(`${captureCommand} \\; ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })
);
const rawCapture = joinScrollbackAndFrame(
scrollbackOverride,
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
combined?.frame ?? exec(captureCommand, execOpts),
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
);
// Query the cursor BEFORE deciding anything else. On the full-history path
@@ -4294,10 +4363,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
combined
? combined.cursor
: exec(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })
);
// Report the size the pane was really drawing at. The visible-frame path
// below addresses every row absolutely, so a consumer whose terminal is
@@ -4363,6 +4431,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest).
* Split out like {@link inputRunner} so a test subclass can count and script the
* calls a capture makes.
*/
protected captureRunner():
| ((cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string)
| null {
if (IS_TEST_MODE) return null;
return (cmd, opts) => execSync(cmd, opts);
}
/**
* Capture the active pane for a tmux session.
*
+42 -5
View File
@@ -27,11 +27,18 @@
* per tmux server in practice.
*
* That tolerance is server-wide like every key table: while such an alias is
* bound, a browser tab's client can detach itself with it too. The same goes
* for the `root` keys an attach claims, including Alt+1..9, which run
* `switch-client`: a tab that receives Esc then a digit within tmux's
* `escape-time` is moved onto another session for as long as those bindings
* exist. Leaving `root` alone keeps the attach working; it does not close that.
* bound, a browser tab's client can detach itself with it too. F1 in `root`
* is the same kind of way out. Alt+1..9 are not: `codeman tui` binds those
* through `if-shell -F` so `switch-client` runs only for the TUI's own client
* (see `bindSwitchKey` in src/tui/tui-client.ts). This file still does not
* touch `root`.
*
* Docker cases and owned remote sessions run a second tmux (`-L codeman-docker`,
* `-L codeman-remote`) with `prefix C-q`. That server is not this socket, so
* the reset above never sees it, and its stock prefix table still switches
* the inner client. `nestedPrefixTableResetCommands` is the same reduction,
* appended to those create chains. A non-owned remote attach joins another
* Codeman's `-L codeman` and must not run it.
*
* @module tmux-prefix-table
*/
@@ -43,6 +50,36 @@ import { shellescape } from './remote-hosts.js';
/** tmux's stock prefix, used when `show-options` cannot be read. */
const DEFAULT_PREFIX = 'C-b';
/**
* Prefix on the dedicated tmux inside a Docker case and an owned remote
* session. Codeman's own socket keeps the user's prefix; these nested servers
* always use C-q so it does not collide with the outer prefix.
*/
export const NESTED_TMUX_PREFIX = 'C-q';
/**
* Commands that reduce a nested tmux's prefix table to `C-q send-prefix`.
*
* Joined with `\;` onto the `new-session` that creates the server
* (`buildRemoteLaunchCommand`, `buildDockerLaunchCommand`). No `tmux` binary
* and no `-g`: the table is server-global, which is what we want on these
* dedicated sockets and nowhere else.
*
* Those servers load the user's tmux config (no `-f /dev/null`). A config that
* leaves the prefix table empty makes `unbind-key -a -T prefix` fail with
* "table prefix doesn't exist", and tmux then drops every later command in the
* `\;` chain, including the bind and `remain-on-exit failed` (#210). The same
* empty table is what #669 handled on the local socket. Binding `send-prefix`
* first creates the table so the unbind can run; the unbind clears stock
* bindings (and that first bind); the second bind puts `send-prefix` back.
* Put them BEFORE `remain-on-exit failed` so a remote tmux that rejects
* `failed` does not drop the reduction.
*/
export function nestedPrefixTableResetCommands(): readonly [string, string, string] {
const sendPrefix = `bind-key -T prefix ${NESTED_TMUX_PREFIX} send-prefix`;
return [sendPrefix, 'unbind-key -a -T prefix', sendPrefix];
}
/** One `list-keys -T prefix` line: key and command. */
const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/;
+43 -8
View File
@@ -56,6 +56,8 @@ import { composerScroll, composerStep, composerText, createComposer, type TuiCom
import { formatAwayDigest } from './tui-digest.js';
import {
ATTACH_BANNER_MARKER,
isTuiClientTty,
readControllingTty,
TuiClient,
type TuiSessionOptions,
type TuiWindowSizing,
@@ -550,7 +552,14 @@ export async function beginAttachHandoff(
label: string,
tabs: readonly TuiAttachTab[] = [],
cols?: number,
rows?: number
rows?: number,
/**
* Tty the attach client will inherit. Omit to read this process's terminal.
* `null` means there is no usable tty: switch keys are not bound (an unscoped
* `switch-client` would move every client on the socket, including a browser
* tab). Tests pass a tty so they do not depend on stdin.
*/
clientTty?: string | null
): Promise<TuiAttachHandoff> {
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
// Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes.
@@ -584,13 +593,29 @@ export async function beginAttachHandoff(
// usable rather than decorative. Only the first nine, only sessions that
// really have a pane, and only keys tmux reports as free — the same rule the
// way-out key follows, so a binding of the user's own is never shadowed.
//
// The key table is server-global. The command is guarded to this terminal's
// client (see bindSwitchKey): without a tty we bind nothing, and a leaked
// unscoped `switch-client` from an older TUI is removed so it cannot keep
// moving browser tabs. When we do claim the keys, drop whatever previous
// attach left on M-1..M-9 first — including keys this strip will not reuse.
const switchKeys: string[] = [];
for (const tab of tabs.slice(0, 9)) {
if (!tab.muxName) continue;
const key = `M-${tab.index}`;
const bound = await client.readPrefixBinding(key, 'root');
if (bound !== null && !bound.startsWith('switch-client')) continue;
if (await client.bindSwitchKey(key, tab.muxName)) switchKeys.push(key);
const tty = clientTty === undefined ? readControllingTty() : clientTty || null;
const switchTabs = tabs.slice(0, 9).filter((tab) => tab.index >= 1 && tab.index <= 9 && tab.muxName);
const guarded = Boolean(tty && isTuiClientTty(tty));
// The keys are bound here but stay inert until the attach hook records this
// client's pid, and that hook is armed at the very end (see below).
if (guarded && switchTabs.length > 0) {
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`);
for (const tab of switchTabs) {
const key = `M-${tab.index}`;
const bound = await client.readPrefixBinding(key, 'root');
// Takeover just cleared ours. Anything still here belongs to the user.
if (bound !== null) continue;
if (await client.bindSwitchKey(key, tab.muxName as string, tty as string)) switchKeys.push(key);
}
} else {
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`, '');
}
const bannerFor = (activeMux: string, ownLabel: string): Record<string, string> =>
buildAttachBanner({
@@ -635,10 +660,20 @@ export async function beginAttachHandoff(
if (cols && rows && rows > 1) await client.presizeWindow(target, cols, rows - 1);
if (target === muxName) await client.followAttachingClient(target);
}
// ⚠️ Armed LAST, as the final tmux call before the caller's `spawnSync`. The
// hook is one-shot for this tty, so a TUI killed between arming and attaching
// leaves it armed, and Linux hands the lowest free pty to the next opener: a
// browser tab's attach client could land on this tty and have its pid
// recorded, which would make these keys move that tab. Arming after the
// dressing loop above shrinks that window to the attach's own startup. A
// failed arm leaves the keys inert (the closed direction), and `restore()`
// still unbinds them.
if (switchKeys.length > 0) await client.armTuiSwitchGuard(tty as string);
return {
chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined),
async restore(): Promise<void> {
for (const key of switchKeys) await client.unbindSwitchKey(key);
for (const key of switchKeys) await client.unbindSwitchKey(key, tty ?? '');
if (tty && isTuiClientTty(tty)) await client.disarmTuiSwitchGuard(tty);
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
if (claimed && alias) await client.unbindDetachKey(alias);
// Options first, then the size: dropping the status bar gives its row
+201 -26
View File
@@ -41,7 +41,7 @@
* @module tui/tui-client
*/
import { execFile as execFileCb } from 'node:child_process';
import { execFile as execFileCb, execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
@@ -445,6 +445,91 @@ export function parsePrefixBinding(stdout: string, key: string, table: TuiKeyTab
return null;
}
/**
* User option holding the tmux client pid of the TUI attach that owns M-1..M-9.
* Set by `TUI_SWITCH_ATTACH_HOOK` when that client attaches, read by the
* `if-shell -F` guard on those keys. A dead pid matches no live client.
*/
export const TUI_SWITCH_CLIENT_PID = '@codeman-tui-pid';
/**
* Indexed so it does not replace a user's own `client-attached` hook. High
* index to stay clear of `[0]`. One-shot: the hook unsets itself after it
* records the pid, so a later client that reuses the pty does not re-arm it.
*/
export const TUI_SWITCH_ATTACH_HOOK = 'client-attached[90]';
/** The keys `bindSwitchKey` binds. The key is interpolated into the binding's `send-keys` else command. */
const TUI_SWITCH_KEY = /^M-[1-9]$/;
/** Linux `/dev/pts/N` and macOS `/dev/ttysNNN`. Anything else is not a client tty we will interpolate into a format. */
const TUI_CLIENT_TTY = /^\/dev\/(?:pts\/[0-9]+|ttys[0-9]+)$/;
export function isTuiClientTty(value: string): boolean {
return TUI_CLIENT_TTY.test(value);
}
/**
* The terminal this process is attached to, when it is a safe tmux client tty.
* `codeman tui` inherits that tty into `tmux attach-session`, which is how the
* guard knows its own client before the client exists. A pipe (tests, no TTY)
* is null: callers then must not bind `switch-client` for every client.
*/
export function readControllingTty(): string | null {
try {
const out = execFileSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'ignore'] }).trim();
return isTuiClientTty(out) ? out : null;
} catch {
return null;
}
}
/**
* `if-shell -F` guard for a TUI switch key. True only for the client whose tty
* is `tty` AND whose pid is the one the attach hook stored. Evaluated when the
* key is pressed, against the client that pressed it.
*/
export function tuiSwitchGuard(tty: string): string {
return `#{&&:#{==:#{client_tty},${tty}},#{==:#{client_pid},#{${TUI_SWITCH_CLIENT_PID}}}}`;
}
/**
* `set-hook` command for `TUI_SWITCH_ATTACH_HOOK`. `\#{client_pid}` is
* defensive. On tmux 3.4 both this form and a bare `#{client_pid}` are stored
* verbatim and both record the attaching client's pid. The escape stays so a
* tmux that expands formats while the hook is stored (no client exists yet)
* cannot save an empty pid.
*/
export function tuiSwitchAttachHook(tty: string): string {
return (
`if-shell -F "#{==:#{client_tty},${tty}}" ` +
`"set -Fg ${TUI_SWITCH_CLIENT_PID} \\"\\#{client_pid}\\"; set-hook -gu ${TUI_SWITCH_ATTACH_HOOK}"`
);
}
const LEGACY_TUI_SWITCH = /^switch-client -t (\S+?)(?: \\; set-window-option -t \1 window-size latest)?$/;
/**
* A root binding this TUI installed: the guarded `if-shell` form, or the
* pre-guard `switch-client -t <mux>` form a killed TUI can leave behind.
* A user's own `switch-client -n` or a target that is not a Codeman session
* is not ours.
*/
export function isTuiSwitchBinding(command: string): boolean {
const text = command.trim();
if (text.includes(TUI_SWITCH_CLIENT_PID) && text.includes('#{client_tty}') && text.includes('switch-client -t ')) {
return true;
}
const legacy = LEGACY_TUI_SWITCH.exec(text);
return legacy !== null && MUX_NAME_PATTERN.test(legacy[1] ?? '');
}
/** The tty embedded in a guarded switch binding, or null for a legacy one. */
export function tuiSwitchBindingTty(command: string): string | null {
const match = /#\{==:#\{client_tty\},(\/dev\/(?:pts\/[0-9]+|ttys[0-9]+))\}/.exec(command);
return match?.[1] ?? null;
}
/**
* The phrase the attach status bar carries. It doubles as the marker that tells
* OUR bar apart from a user's own when sweeping one that leaked, so
@@ -959,21 +1044,33 @@ export class TuiClient {
* `switch-client` rather than detach-then-attach: it keeps the terminal, so
* the move is instant and the TUI stays blocked in its `spawnSync` exactly as
* before, still holding the restore it owes.
*
* The binding is NOT server-wide in effect. Key tables are, so the key is
* bound for every client, but the command is `if-shell -F` and runs only
* when the client that pressed it is this TUI (`clientTty` plus the pid the
* attach hook stored). A browser tab's Esc-then-digit, which tmux reads as
* the same `M-<digit>` inside `escape-time`, does not match. An unsafe tty
* refuses the bind rather than falling back to an unscoped `switch-client`.
*
* A client that fails the guard gets the key back: the if-shell else
* argument is `send-keys <key>`, which writes it into that client's own pane
* exactly as an unbound key would arrive. Without it the root binding ate
* the key, so a browser tab's vim `<Esc>2j` lost its Esc and its digit for
* the whole attach, and for good after a TUI killed before `restore()`. The
* key is interpolated into that command string, so only `M-1`..`M-9` are
* accepted.
*
* The two commands live in the if-shell argument, where `;` is a separator
* inside that string. A bare `;` as its own argv element would end the
* `bind-key` and run `window-size latest` immediately, which is the bug the
* old `\;` argument existed to avoid. The second command is `window-size
* latest`: the window is already the right size on arrival (see
* presizeWindow), so it changes nothing then, and a resize while sitting in
* that session follows this terminal.
*/
async bindSwitchKey(key: string, target: string): Promise<boolean> {
if (!MUX_NAME_PATTERN.test(target)) return false;
async bindSwitchKey(key: string, target: string, clientTty: string): Promise<boolean> {
if (!TUI_SWITCH_KEY.test(key) || !MUX_NAME_PATTERN.test(target) || !isTuiClientTty(clientTty)) return false;
try {
// Two commands: go there, then let that window follow this terminal again.
// It is already the right size (see presizeWindow), so `latest` changes
// nothing on arrival and costs no repaint — it matters only if the
// terminal is resized while sitting in that session.
//
// ⚠️ The separator MUST be an escaped `\;`. A bare `;` is a command
// separator to tmux's own parser, so it ends the `bind-key` and RUNS
// what follows immediately: the binding kept only `switch-client`, and
// every session got `window-size latest` executed on it at attach time.
// That is what left sessions on `latest` after a detach, since the
// snapshot was then taken from already-corrupted state.
await this.exec('tmux', [
'-L',
this.socket,
@@ -981,15 +1078,11 @@ export class TuiClient {
'-T',
'root',
key,
'switch-client',
'-t',
target,
'\\;',
'set-window-option',
'-t',
target,
'window-size',
'latest',
'if-shell',
'-F',
tuiSwitchGuard(clientTty),
`switch-client -t ${target} ; set-window-option -t ${target} window-size latest`,
`send-keys ${key}`,
]);
return true;
} catch {
@@ -997,10 +1090,92 @@ export class TuiClient {
}
}
/** Give back a switch key, but only while it still points at a session. */
async unbindSwitchKey(key: string): Promise<void> {
/**
* Record this TUI's client pid when it attaches, then drop the hook so a
* later client on a reused pty does not take the binding over. Best effort:
* a failure means Alt+1..9 never switch (they reach the pane as Esc then
* the digit), which is the closed direction.
*/
async armTuiSwitchGuard(clientTty: string): Promise<boolean> {
if (!isTuiClientTty(clientTty)) return false;
try {
await this.exec('tmux', [
'-L',
this.socket,
'set-hook',
'-g',
TUI_SWITCH_ATTACH_HOOK,
tuiSwitchAttachHook(clientTty),
]);
return true;
} catch {
return false;
}
}
/**
* Undo `armTuiSwitchGuard` for this tty. The hook is removed only while it
* still names `clientTty` (a second TUI may have replaced it). The pid option
* is removed only when no live client still has it, so a second attach that
* already recorded its own pid is left alone.
*/
async disarmTuiSwitchGuard(clientTty: string): Promise<void> {
if (!isTuiClientTty(clientTty)) return;
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-hooks', '-g']);
const hook = stdout.split('\n').find((line) => line.startsWith(`${TUI_SWITCH_ATTACH_HOOK} `));
// The closed form, not the bare tty: `/dev/pts/4` is a substring of a
// hook armed for `/dev/pts/41`, and removing that one would leave the
// second TUI's Alt keys dead for its whole attach.
if (hook?.includes(`#{client_tty},${clientTty}}`)) {
await this.exec('tmux', ['-L', this.socket, 'set-hook', '-gu', TUI_SWITCH_ATTACH_HOOK]);
}
} catch {
/* no server, or the hook is already gone */
}
let pid = '';
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-options', '-gv', TUI_SWITCH_CLIENT_PID]);
pid = stdout.trim();
} catch {
return;
}
if (!pid) return;
let live = '';
try {
({ stdout: live } = await this.exec('tmux', ['-L', this.socket, 'list-clients', '-F', '#{client_pid}']));
} catch {
live = '';
}
const pids = new Set(
live
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
);
if (pids.has(pid)) return;
try {
await this.exec('tmux', ['-L', this.socket, 'set-option', '-gu', TUI_SWITCH_CLIENT_PID]);
} catch {
/* already unset */
}
}
/**
* Give back a switch key, but only while it is still one of ours.
*
* `ownerTty` set: keep a binding that names a different tty, so this attach's
* restore does not take a second TUI's keys. `ownerTty` omitted: any of our
* bindings on the key goes (the attaching TUI is taking the table over).
* Pass `''` to drop only a legacy unscoped binding.
*/
async unbindSwitchKey(key: string, ownerTty?: string): Promise<void> {
const bound = await this.readPrefixBinding(key, 'root');
if (!bound || !bound.startsWith('switch-client')) return;
if (!bound || !isTuiSwitchBinding(bound)) return;
if (ownerTty !== undefined) {
const boundTty = tuiSwitchBindingTty(bound);
if (boundTty !== null && boundTty !== ownerTty) return;
}
try {
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'root', key]);
} catch {
+15
View File
@@ -33,6 +33,7 @@
import type { RespawnConfig } from './respawn.js';
import type { AttachmentDetectedType } from './tools.js';
import type { GitFetchStatus } from '../git-fetch.js';
/** Status of a Claude session */
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
@@ -891,6 +892,20 @@ export interface SessionState {
keepWarmEnabled?: boolean;
/** What the keep-warm is doing right now; ephemeral, never restored. */
keepWarm?: KeepWarmStatus;
/**
* Git fetch on an interval for this session's repositories; absent = follow the app-wide
* `gitFetchEnabled` default. Restored on a restart, like `keepWarmEnabled`, because the choice is the
* user's and outlives the run.
*/
gitFetchEnabled?: boolean;
/** Seconds between this session's runs; absent = follow the app-wide `gitFetchIntervalSeconds` default. */
gitFetchIntervalSeconds?: number;
/**
* What this session's last fetch did. Written to disk with the rest of the record, but the restore copies
* only the fields its own config lists and this is not one of them, so it is the panel's truth for the
* life of the session, not across a restart.
*/
gitFetch?: GitFetchStatus;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
+7 -5
View File
@@ -34,11 +34,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. With the opt-in tab "+" on (`showTabNewButton`, `html.tabs-show-new`) a third icon joins them and the reserve grows to 68px, still under the 80px `max-width`. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)
@@ -52,7 +52,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
@@ -66,6 +66,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices)
**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle)
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
@@ -86,7 +88,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. ⚠️ The watchdog cannot see an attempt that never OPENS (no response headers: no `open`, no `error`, never `connected`), so each attempt is bounded by `SSE_CONNECT_TIMEOUT_MS` (15s, `CodemanSseStale.CONNECT_TIMEOUT_MS`) and then retried through `_handleSseConnectionFailure()`, the same backoff `onerror` uses. Its timer is cleared at the top of `connectSSE()` BEFORE the offline early return, on `open`, and in `_handleSseConnectionFailure()`; the timer and the handler both ignore an `EventSource` that has been replaced. The window `offline` handler leaves it running on purpose (`navigator.onLine` can report offline on a working network). Tests: `test/sse-connect-timeout.test.ts`. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
+138
View File
@@ -0,0 +1,138 @@
/**
* @fileoverview Opt-in snapshot of the agent inbox, so pending mail between agents
* survives a server restart (the agents themselves keep running in tmux across it).
*
* Off by default: an install that never turns on `agentInboxPersistEnabled` writes
* nothing new to disk. The rules (discussion #558):
* - Off means no file. Switching it off deletes the snapshot; the in-memory inboxes
* stay as they are.
* - Its own file under `dataPath()`, written 0600 through tmp + rename like
* `intents.json`, since messages carry the same kind of text prompts do. It is never
* read by `/api/search` and `sensitive-path.ts` keeps the file viewer off it.
* - Writes are debounced (a burst of posts is one write) and flushed on shutdown.
* - Boot pruning is NOT done here: the server calls `agentInbox.pruneExcept()` only
* after the session restore succeeded, so a failed restore leaves the mail alone.
*
* @module web/agent-inbox-persistence
*/
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../config/instance.js';
import { agentInbox, type AgentInbox } from './agent-inbox.js';
/** A burst of posts (a coordinator fanning out to five workers) becomes one write. */
const WRITE_DEBOUNCE_MS = 250;
export class AgentInboxPersistence {
private enabled = false;
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly inbox: AgentInbox = agentInbox,
/** Resolved per call: test HOMEs change between files. */
private readonly filePath: () => string = () => dataPath('agent-inbox.json'),
private readonly debounceMs: number = WRITE_DEBOUNCE_MS
) {
this.inbox.onChange = () => this.schedule();
}
get isEnabled(): boolean {
return this.enabled;
}
/**
* Boot: apply the setting and, when it is on, load the snapshot into the store.
* Returns how many messages were restored. A missing or unreadable file is an
* empty inbox, never a failed boot.
*/
load(enabled: boolean): number {
this.enabled = enabled;
if (!enabled) {
// A file left from an earlier "on" is removed, so off really means no file.
this.removeFile();
return 0;
}
const path = this.filePath();
if (!existsSync(path)) return 0;
try {
return this.inbox.restore(JSON.parse(readFileSync(path, 'utf-8')));
} catch (err) {
console.warn(`[AgentInbox] Failed to load ${path}, starting empty:`, err);
return 0;
}
}
/**
* The setting changed. On: write the current inboxes now. Off: drop any pending
* write and delete the snapshot; memory is untouched.
*/
setEnabled(enabled: boolean): void {
if (enabled === this.enabled) return;
this.enabled = enabled;
if (enabled) {
this.writeNow();
} else {
this.cancel();
this.removeFile();
}
}
/** Shutdown: write a pending change now instead of losing it with the timer. */
flush(): void {
if (!this.timer) return;
this.cancel();
this.writeNow();
}
/** Test hook: drop a pending write and detach from the store. */
dispose(): void {
this.cancel();
if (this.inbox.onChange) this.inbox.onChange = undefined;
}
private schedule(): void {
if (!this.enabled || this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
this.writeNow();
}, this.debounceMs);
// A pending write must not hold the process open; shutdown flushes it explicitly.
this.timer.unref();
}
private cancel(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
private writeNow(): void {
if (!this.enabled) return;
const path = this.filePath();
const tmpPath = `${path}.tmp`;
try {
mkdirSync(dirname(path), { recursive: true });
// 0600: agent messages can carry anything a prompt can, secrets included.
// `mode` applies only when the file is created, and the rename makes every
// write a fresh file, so the final file is always 0600.
rmSync(tmpPath, { force: true });
writeFileSync(tmpPath, JSON.stringify(this.inbox.snapshot()), { mode: 0o600 });
renameSync(tmpPath, path);
} catch (err) {
console.warn(`[AgentInbox] Failed to persist ${path}:`, err);
}
}
private removeFile(): void {
const path = this.filePath();
try {
rmSync(path, { force: true });
rmSync(`${path}.tmp`, { force: true });
} catch (err) {
console.warn(`[AgentInbox] Failed to remove ${path}:`, err);
}
}
}
/** Process-wide instance wired to `agentInbox`; the server and the settings route use it. */
export const agentInboxPersistence = new AgentInboxPersistence();
+307
View File
@@ -0,0 +1,307 @@
/**
* @fileoverview Agent inbox: a per-session mailbox other agents (or the user) post to,
* and the receiving agent reads when it chooses to.
*
* The other agent-to-agent channel, `POST /api/sessions/:id/input`, types into the
* receiver's composer. That is right for a prompt and wrong for a message: every
* delivery starts or interrupts a turn, the receiver cannot defer it, and it is one
* line only. The inbox stores instead of typing. Nothing here ever reaches a pane.
*
* Invariants (each pinned in `test/agent-inbox.test.ts`):
* - **Read until ack.** A read hands out every pending message and removes nothing.
* A message goes only when the receiver acknowledges its id. "Read and dropped"
* and "done" must not look the same to the sender (discussion #558, the 09-23 case).
* - **Seen is the receiver's alone.** `read(…, { receiver: true })` marks what it
* handed out as seen; any other read is a peek and changes nothing. The route
* decides who the receiver is; this module only takes the flag.
* - **Bounded, and full refuses.** `MAX_MESSAGES_PER_INBOX`, `MAX_MESSAGE_BYTES` and
* `MAX_INBOX_BYTES`. A post that would cross any of them is refused so the sender
* learns. The oldest message is never dropped to make room.
* - **TTL from the message's own timestamp** (`MESSAGE_TTL_MS`). A restore keeps
* `createdAt`, so a restart does not restart the clock. Expiry is lazy: every
* accessor sweeps the inbox it touches, `summary()` and `snapshot()` sweep all.
* - **No IO, no `Session` import.** Persistence (`agent-inbox-persistence.ts`) hooks
* `onChange`; long-poll waiters live in `session-wait-registry.ts`.
*
* @consumedby web/routes/inbox-routes, web/agent-inbox-persistence, web/server
* @module web/agent-inbox
*/
import { randomUUID } from 'node:crypto';
// ─── Bounds ──────────────────────────────────────────────────────────────────
/** Messages one inbox holds before posts are refused. */
export const MAX_MESSAGES_PER_INBOX = 200;
/** UTF-8 bytes per message. A bigger payload belongs in the workspace; post its path. */
export const MAX_MESSAGE_BYTES = 16_384;
/** UTF-8 bytes of message text one inbox holds before posts are refused. */
export const MAX_INBOX_BYTES = 512 * 1024;
/** A message older than this is gone, read or not (same span as the approvals inbox). */
export const MESSAGE_TTL_MS = 12 * 60 * 60 * 1000;
// ─── Types ───────────────────────────────────────────────────────────────────
/** A message as it is stored (and persisted). */
interface StoredMessage {
id: string;
/** The posting session's full id, or null when no session could be resolved (the user). */
from: string | null;
text: string;
/** Epoch ms. The TTL counts from here. */
createdAt: number;
/** Epoch ms of the first receiver read that handed this message out; null while unseen. */
seenAt: number | null;
}
/** A message as a read hands it out. */
export interface InboxMessage {
id: string;
from: string | null;
text: string;
createdAt: number;
/** True when a receiver read had handed this message out BEFORE this read. */
seen: boolean;
}
export interface InboxReadResult {
/** Every pending message, oldest first: unseen and seen-but-unacked alike. */
messages: InboxMessage[];
pending: number;
/** Unseen after this read (0 after a receiver read; unchanged by a peek). */
unseen: number;
}
export type PostResult =
| { ok: true; message: InboxMessage; pending: number; unseen: number }
| { ok: false; reason: 'empty' | 'message-too-large' | 'inbox-full-count' | 'inbox-full-bytes' };
/** What `summary()` reports for one session. Counts and times only, never text. */
export interface InboxCounts {
pending: number;
unseen: number;
/** `createdAt` of the oldest unseen message, null when nothing is unseen. */
oldestUnseenAt: number | null;
}
/** On-disk shape. `version` lets a later format refuse an older file instead of misreading it. */
export interface InboxSnapshot {
version: 1;
inboxes: Record<string, StoredMessage[]>;
}
// ─── Store ───────────────────────────────────────────────────────────────────
export class AgentInbox {
private readonly inboxes = new Map<string, StoredMessage[]>();
/** Any mutation (post, seen, ack, expiry, drop, prune, restore excluded). Persistence hooks this. */
onChange: (() => void) | undefined;
constructor(private readonly now: () => number = Date.now) {}
/**
* Store a message for `sessionId`. `from` is the RESOLVED sender (the route's job),
* never a client-supplied label.
*/
post(sessionId: string, from: string | null, text: string): PostResult {
if (text.length === 0) return { ok: false, reason: 'empty' };
const bytes = Buffer.byteLength(text, 'utf8');
if (bytes > MAX_MESSAGE_BYTES) return { ok: false, reason: 'message-too-large' };
const queue = this.live(sessionId);
if (queue.length >= MAX_MESSAGES_PER_INBOX) return { ok: false, reason: 'inbox-full-count' };
if (inboxBytes(queue) + bytes > MAX_INBOX_BYTES) return { ok: false, reason: 'inbox-full-bytes' };
const stored: StoredMessage = { id: randomUUID(), from, text, createdAt: this.now(), seenAt: null };
queue.push(stored);
this.inboxes.set(sessionId, queue);
this.onChange?.();
return { ok: true, message: view(stored), pending: queue.length, unseen: countUnseen(queue) };
}
/**
* Every pending message, oldest first. Removes nothing. With `receiver`, the
* messages handed out are marked seen (each keeps its first `seenAt`); without it
* this is a peek and changes nothing at all.
*/
read(sessionId: string, options: { receiver: boolean }): InboxReadResult {
const queue = this.live(sessionId);
const messages = queue.map(view);
if (options.receiver) {
let marked = false;
const at = this.now();
for (const m of queue) {
if (m.seenAt !== null) continue;
m.seenAt = at;
marked = true;
}
if (marked) this.onChange?.();
}
return { messages, pending: queue.length, unseen: countUnseen(queue) };
}
/** Messages no receiver read has handed out yet. A `?wait=` read parks while this is 0. */
unseenCount(sessionId: string): number {
return countUnseen(this.live(sessionId));
}
pendingCount(sessionId: string): number {
return this.live(sessionId).length;
}
/**
* Remove the given messages. Idempotent: an id that is unknown or already gone is
* not an error, so a retry after a lost response succeeds. Returns how many went.
*/
ack(sessionId: string, ids: readonly string[]): number {
const queue = this.live(sessionId);
if (queue.length === 0 || ids.length === 0) return 0;
const drop = new Set(ids);
const kept = queue.filter((m) => !drop.has(m.id));
const removed = queue.length - kept.length;
if (removed === 0) return 0;
this.store(sessionId, kept);
this.onChange?.();
return removed;
}
/** Per-session counts for every inbox that holds anything. Never blocks. */
summary(): Map<string, InboxCounts> {
const out = new Map<string, InboxCounts>();
for (const sessionId of [...this.inboxes.keys()]) {
const queue = this.live(sessionId);
if (queue.length === 0) continue;
const unseen = queue.filter((m) => m.seenAt === null);
out.set(sessionId, {
pending: queue.length,
unseen: unseen.length,
oldestUnseenAt: unseen.length > 0 ? unseen[0].createdAt : null,
});
}
return out;
}
/** The session was DELETED: its mail goes with it. Not called on detach or shutdown. */
drop(sessionId: string): void {
if (this.inboxes.delete(sessionId)) this.onChange?.();
}
/**
* Boot-time sweep, run only after the session restore succeeded: discard inboxes
* whose session did not come back. Every route needs a live session, so an orphan
* could otherwise never be removed. Returns how many inboxes were dropped.
*/
pruneExcept(keep: Iterable<string>): number {
const alive = new Set(keep);
let dropped = 0;
for (const sessionId of [...this.inboxes.keys()]) {
if (alive.has(sessionId)) continue;
this.inboxes.delete(sessionId);
dropped++;
}
if (dropped > 0) this.onChange?.();
return dropped;
}
/** Whole-store snapshot for persistence, expired messages already swept. */
snapshot(): InboxSnapshot {
const inboxes: Record<string, StoredMessage[]> = {};
for (const sessionId of [...this.inboxes.keys()]) {
const queue = this.live(sessionId);
if (queue.length > 0) inboxes[sessionId] = queue.map((m) => ({ ...m }));
}
return { version: 1, inboxes };
}
/**
* Replace the store from a snapshot (server boot). Malformed input is skipped entry
* by entry rather than thrown: a corrupt file must not stop the server. Expired
* messages are not restored, and the caps hold for what is. Does not fire
* `onChange`: writing back what was just read would be a no-op at best.
*/
restore(snapshot: unknown): number {
const data = snapshot as Partial<InboxSnapshot> | null;
this.inboxes.clear();
if (!data || data.version !== 1 || typeof data.inboxes !== 'object' || data.inboxes === null) return 0;
const cutoff = this.now() - MESSAGE_TTL_MS;
let restored = 0;
for (const [sessionId, raw] of Object.entries(data.inboxes)) {
if (!Array.isArray(raw)) continue;
const queue: StoredMessage[] = [];
let bytes = 0;
// Oldest first whatever order the file has, so the caps keep the oldest mail,
// which is the rule for posts too.
const entries = raw
.filter((entry): entry is StoredMessage => isStoredMessage(entry) && entry.createdAt > cutoff)
.sort((a, b) => a.createdAt - b.createdAt);
for (const entry of entries) {
const size = Buffer.byteLength(entry.text, 'utf8');
if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES) break;
queue.push({ ...entry });
bytes += size;
}
if (queue.length === 0) continue;
this.inboxes.set(sessionId, queue);
restored += queue.length;
}
return restored;
}
/** Test hook: back to an empty store with no listener. */
resetForTests(): void {
this.inboxes.clear();
this.onChange = undefined;
}
/** The inbox with expired messages swept out (fires `onChange` when it swept any). */
private live(sessionId: string): StoredMessage[] {
const queue = this.inboxes.get(sessionId);
if (!queue) return [];
const cutoff = this.now() - MESSAGE_TTL_MS;
// Every message, not only queue[0]: a clock stepping back can put an older
// createdAt behind a newer one. At most 200 entries, so the scan is cheap.
if (!queue.some((m) => m.createdAt <= cutoff)) return queue;
const kept = queue.filter((m) => m.createdAt > cutoff);
this.store(sessionId, kept);
this.onChange?.();
return kept;
}
private store(sessionId: string, queue: StoredMessage[]): void {
if (queue.length === 0) this.inboxes.delete(sessionId);
else this.inboxes.set(sessionId, queue);
}
}
function view(m: StoredMessage): InboxMessage {
return { id: m.id, from: m.from, text: m.text, createdAt: m.createdAt, seen: m.seenAt !== null };
}
function countUnseen(queue: readonly StoredMessage[]): number {
let n = 0;
for (const m of queue) if (m.seenAt === null) n++;
return n;
}
function inboxBytes(queue: readonly StoredMessage[]): number {
let total = 0;
for (const m of queue) total += Buffer.byteLength(m.text, 'utf8');
return total;
}
function isStoredMessage(value: unknown): value is StoredMessage {
const m = value as Partial<StoredMessage> | null;
return (
!!m &&
typeof m.id === 'string' &&
(m.from === null || typeof m.from === 'string') &&
typeof m.text === 'string' &&
m.text.length > 0 &&
Buffer.byteLength(m.text, 'utf8') <= MAX_MESSAGE_BYTES &&
typeof m.createdAt === 'number' &&
Number.isFinite(m.createdAt) &&
(m.seenAt === null || typeof m.seenAt === 'number')
);
}
/** Process-wide store used by the routes and the server wiring. Tests construct their own. */
export const agentInbox = new AgentInbox();
+172 -50
View File
@@ -9,7 +9,7 @@
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
@@ -17,6 +17,7 @@ import {
AUTH_SESSION_TTL_MS,
MAX_AUTH_SESSIONS,
AUTH_FAILURE_MAX,
AUTH_IP_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
@@ -32,7 +33,7 @@ import {
lostWebviewFramePage,
LOST_FRAME_PAGE_CSP,
} from '../webview-proxy.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type UserRecord } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
// ownership helpers default to a synthetic admin (see route-helpers).
@@ -53,6 +54,8 @@ interface AuthState {
hookSecretFailures: StaleExpirationMap<string, number> | null;
/** Per-username Basic-auth failure bucket (multi-user only). */
userFailures: StaleExpirationMap<string, number> | null;
/** Per-(username, client IP) Basic-auth failure bucket (multi-user only). */
userIpFailures: StaleExpirationMap<string, number> | null;
}
/** Rate-limit response for a client that exceeded the failure cap. */
@@ -76,6 +79,22 @@ function parseBasicAuth(header?: string): { username: string; password: string }
}
}
/**
* Key of the multi-user in-flight verify map: a SHA-256 digest of (client IP,
* normalized username, password), so the plaintext password is never held as a key.
* The JSON array keeps the three parts unambiguous for any strings (a NUL-joined
* string is not, since a username may carry a NUL). Never logged or persisted, and
* gone from the map once its verify settles.
*/
function verifyFlightKey(clientIp: string, normUser: string, password: string): string {
return createHash('sha256')
.update(JSON.stringify([clientIp, normUser, password]))
.digest('hex');
}
/** What one Basic verify decided, shared by the request that ran it and every joiner. */
type BasicVerifyOutcome = { user: UserRecord } | { user: null; limited: boolean };
/**
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
* single-user and multi-user auth hooks so the security-critical logic has ONE
@@ -303,6 +322,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
qrAuthFailures: null,
hookSecretFailures: null,
userFailures: null,
userIpFailures: null,
};
// Always declare req.authUser so downstream reads are safe (single-user leaves it
@@ -350,13 +370,33 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
const hookSecretFailures = state.hookSecretFailures;
if (multiUser) {
// Per-username failure bucket: a botnet can't brute-force one account across
// many IPs, and one user behind a NAT can't lock out everyone else.
// Per-username failure bucket: failed guesses against one account answer 429
// from any IP once it fills, and one user behind a NAT can't lock out everyone
// else. Checked after the verify, so it never denies a correct password.
state.userFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
// Per-(username, IP) failure bucket: checked BEFORE the verify, so it is what
// caps verified guesses against one account from one source. Keyed on the pair,
// so other IPs can't lock the account out and two users behind one shared IP
// don't lock each other out. Same decay as the other buckets: it holds one key
// per (IP, username) pair whose verify failed or is still in flight, swept by
// the same TTL, so like them it grows only with scrypt verifies.
state.userIpFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(
app,
https,
authSessions,
authFailures,
hookSecretFailures,
state.userFailures,
state.userIpFailures,
basePath
);
return state;
}
@@ -465,8 +505,9 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
/**
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
* `username:password` against the user store, mints an identity-carrying cookie,
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
* decorates `req.authUser`, enforces the per-IP, per-(username, IP) and per-username
* rate limits, and the `mustChangePassword` lockbox. Identical logins in flight at once
* share one verify. The single-user hook above is left untouched.
*/
function registerMultiUserAuthHook(
app: FastifyInstance,
@@ -475,6 +516,7 @@ function registerMultiUserAuthHook(
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>,
userIpFailures: StaleExpirationMap<string, number>,
basePath = ''
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
@@ -515,6 +557,75 @@ function registerMultiUserAuthHook(
return false;
};
// Basic verifies in flight, by verifyFlightKey(). A request whose identical
// credentials from the same IP are already being verified awaits that verify
// instead of starting its own. Each entry is removed when its verify settles, so the
// map never holds more than the distinct verifies in flight.
const verifiesInFlight = new Map<string, Promise<BasicVerifyOutcome>>();
// The verify plus its once-only side effects, run by the first request for a key.
// A success clears the three buckets and records the login; a failure is counted
// here once (the per-IP and pair buckets already hold it: the caller reserved them).
const verifyOnce = async (
username: string,
password: string,
normUser: string,
clientIp: string,
pairKey: string
): Promise<BasicVerifyOutcome> => {
const result = await verifyPassword(username, password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
userIpFailures.delete(pairKey);
return { user };
}
// Failed guess: only the per-username bucket counts it here. Once that bucket
// reaches the cap, further FAILED attempts get 429 from any IP, but this path is
// only reached on a wrong password, so it can never deny a correct one. Its count
// is re-read after the await, so concurrent failures against one account each add one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
return { user: null, limited: uFail >= AUTH_FAILURE_MAX };
};
// Answer one request from a shared outcome. Every successful request, joiners
// included, gets its own cookie session under the usual capacity rules.
const sendBasicOutcome = (
req: FastifyRequest,
reply: FastifyReply,
outcome: BasicVerifyOutcome,
normUser: string
): void => {
if (outcome.user) {
const { user } = outcome;
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: req.ip,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
return;
}
if (outcome.limited) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
};
app.addHook('onRequest', async (req, reply) => {
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass' || bypass === 'rejected') return;
@@ -569,58 +680,69 @@ function registerMultiUserAuthHook(
return;
}
// 2. Basic Auth against the user store (scrypt verify).
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
// per-username bucket here; see below).
// 2. Basic Auth against the user store (scrypt verify). Three failure buckets:
// - per IP (AUTH_IP_FAILURE_MAX), checked first: bounds scrypt CPU cost from one
// source. It refuses even a correct password, so it is wider than
// AUTH_FAILURE_MAX: behind a shared egress IP (NAT, a tunnel) a narrow cap
// would let a few wrong guesses lock everyone out.
// - per (username, IP) (AUTH_FAILURE_MAX), checked BEFORE the verify: caps
// verified guesses against one account from one source. Only that IP is
// refused for that account; the same account from another IP still logs in.
// - per username (AUTH_FAILURE_MAX), checked AFTER the verify; see below.
// Neither pre-verify refusal is counted, since nothing was verified (and counting
// a locked pair's retries would fill the shared per-IP bucket for everyone).
// A request that passes both gates RESERVES its slot in both buckets before the
// scrypt await, as if it had already failed. Counting only after the verify let a
// concurrent burst pass the gates on the same stale read: 29 wrong guesses plus the
// right password, all in flight at once from one IP, logged in. A success deletes
// the reservation with the rest of the buckets; a failure is not counted again.
// A request whose IDENTICAL credentials from the same IP are already being verified
// joins that verify instead: it skips both gates, takes no slot, runs no scrypt and
// gets the same outcome, since it adds no guess and no CPU. Reserving for each of
// them refused a correct password with the slots that password itself had filled,
// which is every reload after a restart: cookie sessions live in memory, so the
// browser replays its cached Basic header on many parallel requests at once. A
// request with no verify in flight for its credentials is gated as normal.
const creds = parseBasicAuth(req.headers.authorization);
const normUser = creds ? creds.username.trim().toLowerCase() : '';
const flightKey = creds ? verifyFlightKey(clientIp, normUser, creds.password) : '';
const shared = creds ? verifiesInFlight.get(flightKey) : undefined;
if (shared) {
sendBasicOutcome(req, reply, await shared, normUser);
return;
}
const ipFail = authFailures.get(clientIp) ?? 0;
if (ipFail >= AUTH_FAILURE_MAX) {
if (ipFail >= AUTH_IP_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
const creds = parseBasicAuth(req.headers.authorization);
if (creds) {
const normUser = creds.username.trim().toLowerCase();
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
// per-username bucket before verifying let throwaway IPs lock out a known account
// (incl. admin) even with the correct password. A correct password must always
// win and self-heal both buckets, regardless of the username-failure count.
const result = await verifyPassword(creds.username, creds.password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: clientIp,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
// IPs never contain a newline, so this key is unambiguous for any username.
const pairKey = `${clientIp}\n${normUser}`;
const pairFail = userIpFailures.get(pairKey) ?? 0;
if (pairFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userIpFailures, pairKey);
return;
}
// Failed guess: count it against BOTH buckets. Once the per-username bucket
// reaches the cap, further FAILED attempts get 429 (throttles distributed
// brute-force), but this path is only reached on a wrong password, so it can
// never deny a correct one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
// Reserve both slots and publish this verify for joiners, from the values the
// gates just read. Correct only while nothing between those reads and these
// writes awaits: keep it that way.
authFailures.set(clientIp, ipFail + 1);
if (uFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
userIpFailures.set(pairKey, pairFail + 1);
// Security: the per-username bucket is consulted only AFTER the verify, and only
// FAILED attempts are throttled by it. Consulting it before verifying let
// throwaway IPs lock out a known account (incl. admin) even with the correct
// password. A correct password that passes the two gates above always wins and
// self-heals the per-IP, per-username and this pair's buckets.
const verify = verifyOnce(creds.username, creds.password, normUser, clientIp, pairKey);
verifiesInFlight.set(flightKey, verify);
let outcome: BasicVerifyOutcome;
try {
outcome = await verify;
} finally {
verifiesInFlight.delete(flightKey);
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
sendBasicOutcome(req, reply, outcome, normUser);
return;
}
+16 -3
View File
@@ -7,15 +7,28 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
}
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
* True when an unauthenticated non-loopback BIND is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Quiets the startup
* warning only; it does not cover the Cloudflare tunnel (#706), which has its own
* acknowledgement below.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
/**
* True when the operator opted in to an unauthenticated public Cloudflare tunnel:
* `--allow-unauthenticated-tunnel` (passed in as `allowFlag`) or
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL. Used by the tunnel-enable guard (COD-55) in
* place of a login. Kept apart from the network acknowledgement on purpose (#706):
* install.sh writes that one for every passwordless LAN install, and it must never
* also publish the machine to the internet. install.sh never writes this one.
*/
export function isUnauthenticatedTunnelAcknowledged(allowFlag = false): boolean {
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL);
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
+2
View File
@@ -35,6 +35,8 @@ export interface InfraPort {
readonly scheduledRuns: Map<string, ScheduledRun>;
readonly teamWatcher: TeamWatcher;
readonly tunnelManager: TunnelManager;
/** `--allow-unauthenticated-tunnel` was passed to `codeman web` (the env var is read per request, #706). */
readonly allowUnauthenticatedTunnel: boolean;
readonly pushStore: PushSubscriptionStore;
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
stopScheduledRun(id: string): Promise<void>;
+330 -88
View File
@@ -997,6 +997,12 @@ class CodemanApp {
this._sseLastMessageAt = 0;
this._sseStaleTimeoutMs = window.CodemanSseStale?.TIMEOUT_MS ?? 45000;
this._sseStaleWatchdog = null;
// Connect timeout: the watchdog only judges a 'connected' stream, and an
// attempt whose request hangs never opens AND never errors. Each attempt
// is bounded by this timer (armed in connectSSE, cleared on open, on
// failure and at the top of every connectSSE so timers never stack).
this._sseConnectTimeout = null;
this._sseConnectTimeoutMs = window.CodemanSseStale?.CONNECT_TIMEOUT_MS ?? 15000;
// Connection-loss UI (banner + full-screen overlay). The decision itself is
// pure and lives in constants.js (computeConnectionLossUi); these are just
@@ -2066,6 +2072,10 @@ class CodemanApp {
}
connectSSE() {
// First, before the offline early return: any path into connectSSE
// supersedes the previous attempt, and its deadline must go with it.
this._clearTimer('_sseConnectTimeout');
// Check if browser is offline
if (!navigator.onLine) {
this.setConnectionStatus('offline');
@@ -2109,7 +2119,21 @@ class CodemanApp {
const _sseParams = new URLSearchParams({ clientId: this._clientId });
const _sseFilterId = this._sseFilterSessionId(this.activeSessionId);
if (_sseFilterId) _sseParams.set('sessions', _sseFilterId);
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
const eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
this.eventSource = eventSource;
// Bound the attempt. A request that never gets response headers leaves
// the EventSource in CONNECTING with no `open` and no `error`, so without
// this the page waits forever. readyState OPEN covers the narrow race
// where the connection opened but its `open` callback has not run yet.
const connectTimeout = setTimeout(() => {
if (this._sseConnectTimeout !== connectTimeout) return;
this._sseConnectTimeout = null;
if (this.eventSource !== eventSource || eventSource.readyState === EventSource.OPEN) return;
console.warn(`[SSE] connection did not open within ${this._sseConnectTimeoutMs}ms, retrying`);
this._handleSseConnectionFailure(eventSource);
}, this._sseConnectTimeoutMs);
this._sseConnectTimeout = connectTimeout;
// Store all event listeners for cleanup on reconnect.
//
@@ -2138,6 +2162,7 @@ class CodemanApp {
};
this.eventSource.onopen = () => {
this._clearTimer('_sseConnectTimeout');
this.reconnectAttempts = 0;
// Start the liveness clock here, not at the first frame: the watchdog
// only ever fires while the status is 'connected', and this is the
@@ -2145,31 +2170,7 @@ class CodemanApp {
this._sseLastMessageAt = Date.now();
this.setConnectionStatus('connected');
};
this.eventSource.onerror = () => {
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.setConnectionStatus('disconnected');
} else {
this.setConnectionStatus('reconnecting');
}
// Close the failed connection before scheduling reconnect
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
this._clearTimer('sseReconnectTimeout');
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
// silent wait that long is indistinguishable from a hung app.
this._nextSseRetryAt = Date.now() + delay;
this._updateConnectionLossUi();
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
};
this.eventSource.onerror = () => this._handleSseConnectionFailure(eventSource);
// Create stable handler wrappers once (reused across reconnects so
// removeEventListener always matches the original reference)
@@ -2332,6 +2333,38 @@ class CodemanApp {
this._sseStaleWatchdog = setInterval(() => this._checkSseStale(), 5000);
}
/**
* Close a failed SSE attempt (onerror, or the connect timeout) and schedule
* the normal exponential-backoff retry.
*/
_handleSseConnectionFailure(eventSource) {
// A late error or timeout from an EventSource that has since been
// replaced must not close or reschedule the connection that replaced it.
if (this.eventSource !== eventSource) return;
this._clearTimer('_sseConnectTimeout');
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.setConnectionStatus('disconnected');
} else {
this.setConnectionStatus('reconnecting');
}
// Close the failed connection before scheduling reconnect
this.eventSource = null;
eventSource.close();
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
this._clearTimer('sseReconnectTimeout');
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
// silent wait that long is indistinguishable from a hung app.
this._nextSseRetryAt = Date.now() + delay;
this._updateConnectionLossUi();
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
}
/**
* Force a reconnect if the SSE stream has gone quiet while still claiming to
* be connected. Called by the 5s watchdog and on tab-visible.
@@ -2402,8 +2435,6 @@ class CodemanApp {
if (data.parentSessionId) this.markConnectionLineEntering?.('lineage:' + data.id);
this.renderSessionTabs();
this.updateCost();
// Start stats polling when first session appears
if (this.sessions.size === 1) this.startSystemStatsPolling();
if (this._urlSessionId === data.id) this._selectUrlSession();
}
@@ -2421,9 +2452,15 @@ class CodemanApp {
if (session.id === this.activeSessionId && session.tokens) {
this.updateRespawnTokens(session.tokens);
}
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
// A scheduled fetch publishes the session's result on this same event, so an open Session Options modal
// on that session shows it without polling its own (session-ui.js: updateGitFetchStatus, which leaves the
// control being typed in alone).
if (this.editingSessionId === session.id) this.updateGitFetchStatus(session.id);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
if (claudeSessionIdJustSet) {
@@ -2459,8 +2496,8 @@ class CodemanApp {
this.renderSessionTabs();
this.renderRalphStatePanel(); // Update ralph panel after session deleted
this.renderProjectInsightsPanel(); // Update project insights panel after session deleted
// Stop stats polling when no sessions remain
if (this.sessions.size === 0) this.stopSystemStatsPolling();
// Stats polling keeps going with no session left: the header shows CPU, MEM and the opt-in readings
// regardless, and would sit at "--" otherwise.
}
// SSE wrappers — skip terminal events while WebSocket owns active terminal I/O.
@@ -3017,7 +3054,27 @@ class CodemanApp {
const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue;
const found = findFilePathLinks(node.nodeValue || '');
// Markdown puts **Saved to:** in a separate <strong> node. Carry a bold
// label that starts its line into detection and let spacedFilePathOnLine()
// judge it, as it does the same line in the terminal (`Note:` is not a
// path label); never a label from another block. Chat renders with
// `breaks: true`, so a later line of a paragraph starts after a <br>.
// A text node after any other inline element (`Use <code>--config</code>
// /a/b c.json`) starts mid-line, so a placeholder word stands in for what
// precedes it. The label itself stays untouched.
const label = node.previousSibling;
let beforeLabel = label?.previousSibling;
if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling;
const leadingLabel =
label?.nodeName === 'STRONG' &&
/^(?:P|LI)$/.test(node.parentElement?.tagName || '') &&
(!beforeLabel || beforeLabel.nodeName === 'BR');
let prefix = '';
if (leadingLabel) prefix = `${label.textContent} `;
else if (label?.nodeType === 1 && label.nodeName !== 'BR') prefix = 'x ';
const found = findFilePathLinks(prefix + (node.nodeValue || ''))
.filter((hit) => hit.index >= prefix.length)
.map((hit) => ({ ...hit, index: hit.index - prefix.length }));
if (found.length > 0) targets.push([node, found]);
}
@@ -3351,11 +3408,15 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
// fetches wide in the fallback case, so this guard is not optional. The
// pane count is recorded only past it: a refresh that lost its owner is
// older than whatever the switch back read (it would have overwritten a
// fresh 0 with a stale count and held the paging fallback off a hollow
// pane), and an error envelope is not a capture at all.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -3680,7 +3741,9 @@ class CodemanApp {
seg('mo', pct(usage?.monthly), false),
].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
// data-short is what the narrowest header densities show instead of the name (header-divider.js).
const short = { Claude: 'CL', Codex: 'CX', Copilot: 'CP' }[provider] ?? provider.slice(0, 2).toUpperCase();
const label = labelled ? `<span data-short="${short}" class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
@@ -5025,12 +5088,9 @@ class CodemanApp {
// survive reloads and SSE reconnects (methods in approvals-ui.js).
this.seedApprovals?.();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
} else {
this.stopSystemStatsPolling();
}
// Poll the header stats whether or not a session is open (fetchSystemStats skips the call while the
// stats pill is hidden); starting restarts the interval, so only start it once.
if (!this.systemStatsInterval) this.startSystemStatsPolling();
// CRITICAL: Clean up all floating windows before loading new subagents
// This prevents memory leaks from ResizeObservers, EventSources, and DOM elements
@@ -5132,6 +5192,8 @@ class CodemanApp {
// backoff reconnect, but if it is not on this session (dead socket, or
// a give-up) nothing else would re-establish it from here.
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
// The fresh session record may no longer qualify for a keyboard row hold.
this._reconcileKeyboardHold?.();
void this._onSessionNeedsRefresh({ id: restoreId });
// The split pane's second terminal reconnects on its own backoff (up to
// 10 s between tries); the server is back now, so skip the wait. A no-op
@@ -7107,7 +7169,9 @@ class CodemanApp {
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
// The "+" announces a menu, so Enter and Space open it: the strip's own key
// handler acts only on a focused tab, and the header strip has no row menu.
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-new" onclick="event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))})" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))}); }" title="New session here" aria-label="New session in this tab's case" aria-haspopup="menu" tabindex="0">+</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
@@ -7245,24 +7309,7 @@ class CodemanApp {
// the axis a user reaches for there.
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
// drag model honest), so a DOM-order walk steps around the screen instead of
// down it: ArrowDown from the top card lands wherever that session happens to
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
const tabs = this._visibleTabRows(container);
const currentIndex = tabs.indexOf(document.activeElement);
// Enter or Space activates the tab
@@ -7312,6 +7359,52 @@ class CodemanApp {
}
}
/**
* The strip's rows (session and web tabs) in the order the eye reads them,
* for any layout except the grouped tree (see _tabTreeItems).
*/
_visibleTabRows(container) {
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
// drag model honest), so a DOM-order walk steps around the screen instead of
// down it: ArrowDown from the top card lands wherever that session happens to
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
return tabs;
}
/**
* Session ids in the order the session list DRAWS them: case boxes, state
* groups, a sorted rail and named groups all reorder the screen while
* `sessionOrder` stays canonical. Only the phone swipe walks this order
* (`nextSession({ visual: true })`); Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep
* the canonical one, since grouping is a render layer. Web tabs are skipped.
*/
_stripSessionOrder() {
const container = document.getElementById('sessionTabs');
if (!container) return this.sessionOrder;
const rows =
container.getAttribute('role') === 'tree'
? this._tabTreeItems(container).filter((el) => el.classList.contains('session-tab'))
: this._visibleTabRows(container);
// An empty list is a real answer (every row filtered out, a tree collapsed
// to nothing): the swipe then goes nowhere, like the strip. Only a missing
// strip falls back to the canonical order.
return rows.map((row) => row.dataset.id).filter((id) => id && this.sessions.has(id));
}
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
_activateTabRow(row) {
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
@@ -8307,12 +8400,100 @@ class CodemanApp {
return true;
}
/**
* A tab's "+" (and the row menu's "New session here…"): the CLIs that tab's
* case can start, each as its logo and name like the Run menus. A pick starts
* ONE session beside that tab: its case (or the managed worktree it runs in)
* and its named group, if it has one, through `_launchOnce`, so the Run
* button's mode and case are untouched.
*/
openTabNewSessionMenu(event, sessionId) {
const session = this.sessions.get(sessionId);
const worktree = this._managedWorktreeOfSession?.(session) || null;
const caseEntry = worktree ? null : this._tabCaseOf(session);
if (!worktree && !caseEntry) {
event?.stopPropagation?.();
this.showToast?.("This tab's folder is not a case, so there is nothing to start beside it.", 'info');
return false;
}
const caseName = worktree ? worktree.caseName : caseEntry.name;
// An ungrouped tab sends no group, so the server's case default group (if the
// case has one) places the new session, as it does for a toolbar Run.
const tabGroupId = this._tabRefLocation({ kind: 'session', id: sessionId })?.groupId || null;
// The toolbar probes only the case it has selected, so ask this tab's container
// now: this open reads what is already known, the next one the container's answer.
if (caseEntry?.location === 'docker' && !this._dockerCaseModes?.[caseName]) {
void this._probeDockerCaseModes(caseEntry, null);
}
// The phone overview's list, gated by the Run menu's own rule for this case
// (_runModeAvailableForCase: the container's CLIs for a Docker case; shell is
// never gated), in the Run menu's order: agents in catalog order, the shell last.
const isShell = (entry) => registryCliById(entry.mode)?.kind === 'shell';
const available = mobileOverviewRunModes().filter(
(entry) => isShell(entry) || this._runModeAvailableForCase(entry.mode, caseName)
);
const modes = [...available.filter((entry) => !isShell(entry)), ...available.filter(isShell)];
const current = this.runMode;
const actions = modes.map((entry) => ({
label: entry.label,
icon: entry.mode,
className: 'run-mode-option',
selected: entry.mode === current,
run: () =>
this._launchOnce(entry.mode, {
...(worktree ? { worktree } : { caseName }),
...(tabGroupId ? { tabGroupId } : {}),
}),
}));
return this._openTabLayoutMenu(event, `new:${sessionId}`, 'New session here', actions, { anyLayout: true });
}
/**
* The case a session tab belongs to, by what the session runs on. GET /api/cases
* gives a remote or Docker case a DISPLAY path (`user@host:/path`,
* `container:/path`) with the raw values under `remote` / `docker`, while a
* session carries its raw folder, so a path match alone misses those cases or
* lands on a local case at the same folder. A Docker session belongs to its
* container's case on its own engine host, a remote one to the case on its own
* host whose path encloses its own, a local one to the deepest case enclosing
* its folder (a display path never encloses one). Null when none fits.
*/
_tabCaseOf(session) {
if (!session) return null;
const cases = this.cases || [];
const deepestEnclosing = (dir, candidates, rootOf) => {
let best = null;
let bestLength = -1;
for (const c of candidates) {
const root = (rootOf(c) || '').replace(/\/+$/, '');
if (!root || (dir !== root && !dir.startsWith(`${root}/`)) || root.length <= bestLength) continue;
best = c;
bestLength = root.length;
}
return best;
};
if (session.docker) {
const { hostId, containerName } = session.docker;
return (
cases.find((c) => c.location === 'docker' && c.docker?.hostId === hostId && c.docker?.container === containerName) ||
null
);
}
if (session.remote) {
const { hostId, remotePath } = session.remote;
const onHost = cases.filter((c) => c.location === 'remote' && c.remote?.hostId === hostId);
return deepestEnclosing((remotePath || '').replace(/\/+$/, ''), onHost, (c) => c.remote.path);
}
return deepestEnclosing((session.workingDir || '').replace(/\/+$/, ''), cases, (c) => c.path);
}
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/**
* Close the open group / web-tab menu. Every dismissal path lands here:
* Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an
* action, and any full re-render of the rail (which would orphan its trigger).
* Close the open group / web-tab / tab "+" menu. Every dismissal path lands
* here: Escape, a pointer outside it, focus moving to an element outside it,
* Tab, a viewport resize, an action, and any full re-render of the rail (which
* would orphan its trigger).
*/
closeTabGroupMenu({ restoreFocus = false } = {}) {
const menu = this._tabGroupMenu;
@@ -8372,7 +8553,7 @@ class CodemanApp {
]);
}
_openTabLayoutMenu(event, identity, ariaLabel, actions) {
_openTabLayoutMenu(event, identity, ariaLabel, actions, { anyLayout = false } = {}) {
event?.preventDefault?.();
event?.stopPropagation?.();
const trigger = event?.currentTarget || null;
@@ -8383,7 +8564,7 @@ class CodemanApp {
}
this.closeTabGroupMenu();
this.closeTabRailActionMenu?.();
if (!this._tabLayoutEditable()) return false;
if (!anyLayout && !this._tabLayoutEditable()) return false;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu tab-layout-group-action-menu';
menu.setAttribute('role', 'menu');
@@ -8392,8 +8573,17 @@ class CodemanApp {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.icon) {
// A Run menu entry's markup (renderRegistryRunOptions): the CLI's logo, then its name.
const dot = document.createElement('span');
dot.className = `run-mode-dot ${action.icon}`;
dot.setAttribute('aria-hidden', 'true');
button.append(dot, action.label);
} else {
button.textContent = action.label;
}
if (action.className) button.className = action.className;
if (action.selected) button.classList.add('selected');
button.addEventListener('click', () => {
this.closeTabGroupMenu();
action.run();
@@ -8428,6 +8618,12 @@ class CodemanApp {
document.addEventListener('keydown', this._tabGroupMenuKeydown, true);
window.addEventListener('resize', this._tabGroupMenuResize);
menu.addEventListener('keydown', (keyEvent) => {
// A held Enter or Space that opened the menu repeats onto the entry it
// focuses; cancelling the repeat keeps it from also picking that entry.
if (keyEvent.repeat && (keyEvent.key === 'Enter' || keyEvent.key === ' ')) {
keyEvent.preventDefault();
return;
}
const buttons = [...menu.querySelectorAll('button')];
const at = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
@@ -8442,13 +8638,20 @@ class CodemanApp {
this.closeTabGroupMenu({ restoreFocus: true });
}
});
// Focus leaving by any other route (a click elsewhere, a programmatic move).
// Hops between the menu's own items are not a departure.
// Focus moving to another element outside the menu (a programmatic move;
// a click elsewhere is the pointerdown listener's). Hops between the
// menu's own items are not a departure, and neither is focus going nowhere:
// Safari never focuses a clicked button, so a press on an item blurs the
// focused one with no relatedTarget, and closing then dropped its click
// (the tile count menu's rule, tile-grid.js). Nor is focus landing on the
// trigger: a press on a focusable one (a tab's "+") focuses it before its
// click, and closing there let that click open the menu again.
menu.addEventListener('focusout', (focusEvent) => {
if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return;
const to = focusEvent.relatedTarget;
if (!to || menu.contains(to) || trigger?.contains?.(to)) return;
if (this._tabGroupMenu === menu) this.closeTabGroupMenu();
});
menu.querySelector('button')?.focus();
(menu.querySelector('button.selected') || menu.querySelector('button'))?.focus();
return true;
}
@@ -9238,10 +9441,23 @@ class CodemanApp {
// `force` is the user pressing "Load full history" (#258): they asked once,
// explicitly, so the scroll-gesture cooldown does not apply. The downgrade
// guard below still does — a forced pull must not destroy history either.
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
// A refused pull latches 60 s because the browser then held MORE than tmux
// (the downgrade guard below). A tab switch that replayed a hollow tail has
// voided that premise: one screen in the browser while the pane holds
// scrollback, the state `_maybePageCliTranscript` hands to this pull instead
// of paging the CLI. Hold such a buffer to the normal cooldown only, or the
// gesture is dead for up to a minute.
const unloaded =
(this._paneHistoryLines?.get(sessionId) ?? 0) > 0 && (this.terminal?.buffer?.active?.baseY ?? 0) === 0;
const cooldown = !unloaded && this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
// The session-id check below cannot see a tab switch that came BACK during
// the fetch (A -> B -> A): the id matches again while `selectSession` is
// replaying its own capture, and the reset below would land in the middle
// of that write. The select generation moves on every switch, so it can.
const selectGen = this._selectGeneration;
try {
const requestStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
@@ -9253,7 +9469,13 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
// A completion that outlived a tab switch is older than whatever that
// switch recorded about the pane (a switch back re-reads it): keep its
// count out of the memory, or it can overwrite a fresh count with a stale one.
// An error envelope (a JSON 5xx, `success: false`) is not a capture:
// recording its empty data would forget the count and hand the next
// gesture back to the paging fallback, the bug this pull exists to avoid.
if (selectGen === this._selectGeneration && capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -9269,8 +9491,9 @@ class CodemanApp {
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
// history into the terminal the user is now looking at, or this session's
// over the load a switch back has started.
if (!buffer || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
// A bounded window no longer than the browser's buffer buys nothing, and
// resetting to rewrite it would jump the viewport on every scroll that
@@ -9342,7 +9565,7 @@ class CodemanApp {
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
// may be tens of MB; caching it would replay that whole payload again on
// the next tab switch before the normal 1MB tail fetch replaces it.
@@ -9364,6 +9587,10 @@ class CodemanApp {
// reached only from a scroll-up gesture, so being dragged down is the
// exact opposite of what the user asked for.
this._syncStickyScrollBaseline();
// The prompt row moved with the rewrite: re-position the local-echo
// overlay there, as the refresh path does (a phone swipe reaches this
// pull with unsent text on the overlay).
this._localEchoOverlay?.rerender();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
@@ -9411,7 +9638,10 @@ class CodemanApp {
/**
* Remember how much scrollback tmux holds for a session's pane, from any
* terminal response. Called whether or not the response was written, since
* it describes the PANE, not the payload. A response without the field (a
* it describes the PANE, not the payload. Three responses are skipped, since
* none is the newest word on the pane: an error envelope (`success: false`)
* on any path, a refresh that lost its owner, and a history pull whose select
* generation moved during the fetch. A response without the field (a
* byte-history fallback, an older server) forgets it, so the next tab switch
* goes back to the bounded tail: unknown never counts as empty.
*/
@@ -9613,6 +9843,9 @@ class CodemanApp {
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
this._activateFileBrowserSession?.(sessionId);
// A keyboard row hold belongs to the session it was taken for: release it here
// if this one resizes instead (the geometry work below then fits it).
this._reconcileKeyboardHold?.({ resize: false });
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258). The switch lands at
@@ -9943,7 +10176,10 @@ class CodemanApp {
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// An error envelope (a JSON 5xx: _fetchTerminalCapture does not check
// `res.ok`) is not a capture: recording its empty data would forget the
// count the pane last reported, as in the refresh and the history pull.
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
@@ -10612,23 +10848,29 @@ class CodemanApp {
}
}
nextSession() {
/** `{ visual: true }` walks the order the strip draws (the phone swipe), see _stripSessionOrder. */
nextSession(options = {}) {
// With the tile grid open, Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles.
if (this._cycleTileFocus?.(1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[nextIndex]);
const order = options.visual ? this._stripSessionOrder() : this.sessionOrder;
// An active session the list does not show (filtered out, or none active on
// the home screen) steps in at the first row; a lone row that is not the
// active one is still a destination.
const currentIndex = order.indexOf(this.activeSessionId);
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % order.length;
if (!order[nextIndex] || order[nextIndex] === this.activeSessionId) return;
this.selectSession(order[nextIndex]);
}
prevSession() {
prevSession(options = {}) {
if (this._cycleTileFocus?.(-1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const prevIndex = (currentIndex - 1 + this.sessionOrder.length) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[prevIndex]);
const order = options.visual ? this._stripSessionOrder() : this.sessionOrder;
// As above, from the other end: a hidden or absent active session steps in
// at the last row (the old arithmetic landed on the second to last).
const currentIndex = order.indexOf(this.activeSessionId);
const prevIndex = currentIndex < 0 ? order.length - 1 : (currentIndex - 1 + order.length) % order.length;
if (!order[prevIndex] || order[prevIndex] === this.activeSessionId) return;
this.selectSession(order[prevIndex]);
}
// ═══════════════════════════════════════════════════════════════
+622 -12
View File
@@ -828,6 +828,15 @@ function formatKeepWarmStatus(status, now, statusLine) {
// Pure: no DOM, no timers, no side effects. `now` is passed in.
const SSE_STALE_TIMEOUT_MS = 45000; // three missed 15s heartbeats
// The watchdog above cannot see a connect attempt that never opens: an
// EventSource whose request gets no response headers (a resumed phone on a
// VPN, a proxy that accepted the socket and went silent) stays CONNECTING
// without firing `open` or `error`, and its status is never 'connected'.
// connectSSE() bounds each attempt by this much and then retries it through
// the normal backoff. Generous next to a healthy open (well under a second)
// so a slow link is not cut off mid-handshake.
const SSE_CONNECT_TIMEOUT_MS = 15000;
function computeSseStale(input) {
const {
lastMessageAt = null,
@@ -1545,6 +1554,7 @@ if (typeof window !== 'undefined') {
window.CodemanSseStale = {
compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
CONNECT_TIMEOUT_MS: SSE_CONNECT_TIMEOUT_MS,
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
@@ -1914,6 +1924,7 @@ const SSE_EVENTS = {
CASE_CREATED: 'case:created',
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_RENAMED: 'case:renamed',
CASE_ORDER_CHANGED: 'case:order-changed',
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
@@ -2154,12 +2165,15 @@ const RELATIVE_FILE_PATH_LINK_PATTERN =
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* then `~/` and relative ones, then folder paths — each only where it overlaps
* neither an earlier one nor a URL.
* Every linkable file path in `text`, sorted by position. Spaced candidates
* reserve their whole span first, even when unlinkable; rooted, `~/`, relative
* and folder detectors then skip reserved spans, earlier matches and URLs.
*
* The one entry point for both consumers (terminal link provider, response
* viewer), so a path form is clickable in both or in neither.
* viewer). The viewer carries a leading bold label into the text it passes
* (spacedFilePathOnLine judges it, as for the same line in the terminal) and
* reads a text node that follows another inline element as mid-line; paths
* split across inline elements are still detected per text node.
*
* @param {string} text
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
@@ -2173,32 +2187,154 @@ function findFilePathLinks(text) {
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
const webUrlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
// A path with spaces is taken whole, or the span it covers links nothing
// (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
const reserved = [];
let lineStart = 0;
for (const line of value.split('\n')) {
const spaced = spacedFilePathOnLine(line);
if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) {
const index = lineStart + spaced.index;
reserved.push({ index, path: spaced.path });
if (spaced.link) found.push({ path: spaced.path, index });
}
lineStart += line.length + 1;
}
const isReserved = (index, length) =>
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
const absolute = absoluteFilePathPattern();
while ((match = absolute.exec(value)) !== null) {
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
found.push({ path: match[1], index: match.index });
}
}
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
while ((match = relative.exec(value)) !== null) {
const path = match[2];
const index = match.index + match[1].length;
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
if (overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
found.push({ path, index });
}
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
while ((match = folders.exec(value)) !== null) {
const path = folderLinkPath(match[2]);
const index = match.index + match[1].length;
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
if (!path || overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
found.push({ path, index, folder: true });
}
return found.sort((a, b) => a.index - b.index);
}
/** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */
const SPACED_FILE_PATH_LABEL_PATTERN =
/^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i;
/**
* A file path WITH SPACES on one line, or null.
*
* Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
* linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
* wrong targets. A space is only safe inside a path where the line itself bounds
* the path, so a spaced path is recognised in exactly these shapes:
*
* /mnt/c/My Docs/report.docx the whole line is the path
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
*
* An absolute path may stand alone or follow a marker. Any label, before an
* absolute, relative or `~/` path, must be an explicit path label
* (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic
* labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative
* path needs a path label to be taken spaced. One trailing sentence mark is left
* out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path:
* it ends on a known extension, a rooted one has a directory before its first
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has
* no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
* a run of spaces (a column gap) is refused, so two paths on a line are never
* merged. A relative candidate whose only `/` word is its last is prose after the
* label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits
* `Saved to: My Docs/plan.md` as master does. An earlier word that is a lowercase
* connective or verb (SPACED_PATH_PROSE_WORD: `and`, `in`, `has`, `contains`, ...)
* or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`),
* so a name such as `Pros and Cons.md` splits as master does. A command line is
* refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the
* first word, any earlier word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`,
* `/mnt/c/My Docs$ ls a.md`), or any `-x`/`--x` flag word.
*
* A candidate that is one path but not a linkable one (an unknown root such as
* `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last
* word still holds a `/`: its span links nothing, rather than the fragment after
* its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to
* the ordinary detectors.
*
* Linear: one bounded prefix match, then string scans.
*
* @param {string} line One line, no newline.
* @returns {{path: string, index: number, link: boolean} | null}
*/
function spacedFilePathOnLine(line) {
const text = String(line || '');
const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
const index = prefix[0].length;
const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, '');
if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null;
// After a label, only a path label introduces a path; with no label, only an
// absolute path may stand alone. `Error: /a/b contains c.md` is prose.
if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null;
if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null;
// A run of spaces is a column gap (`git status`, `ls -l`), not a name.
if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null;
const words = path.split(' ');
// A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js`
// or `/review the changes in src/a.ts` is a command, not a spaced path.
if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null;
// A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`,
// `/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`, `/home/me/proj/scripts/deploy prod.yml`)
// is not a path either.
if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) {
return null;
}
for (const word of words.slice(0, -1)) {
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
// A shell prompt that prints its working directory in full (`/home/me/proj$ cat
// notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
// one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
// can end any earlier word, not only the first.
if (word.endsWith('$')) return null;
// A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never
// part of a file name, apart from a bare `-` (`OneDrive - Contoso`).
if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null;
// `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix.
if (/:\d/.test(word)) return null;
// An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there.
if (PATH_TOKEN_HAS_EXTENSION.test(word)) return null;
// A connective or verb joins a path to prose (`/tmp/out has the logs in run.log`,
// `Files: src/components and utils/x.ts`), so the candidate is a sentence.
if (SPACED_PATH_PROSE_WORD.test(word)) return null;
}
if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null;
const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/');
const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/');
const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..');
// A path label vouches that a path follows, not for the words before it: when
// only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces
// are prose, so a relative candidate is left to the ordinary detectors.
const firstSlash = words.findIndex((word) => word.includes('/'));
if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null;
// Reserve an unlinkable span only when it still reads as a path to its end;
// otherwise leave the line to the ordinary detectors.
if (!link && !words[words.length - 1].includes('/')) return null;
return { path, index, link };
}
/**
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
@@ -2333,8 +2469,16 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
const continuesPrevious = (r) => {
if (r <= 0) return false;
if (lineAt(r)?.isWrapped) return true;
// A divider row never continues the row above, even when that row reaches
// the right edge: a URL ending at the edge must not absorb `────` below it.
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false;
const prev = lineAt(r - 1);
return !!prev && (prev.translateToString(true) || '').length >= width;
const prevText = prev?.translateToString(true) || '';
// A full-width table divider is a row boundary, not a hard-wrapped line.
// Treating it as a continuation glues the divider onto the row below and
// hides that row's wrapped URL from the link provider.
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false;
return prevText.length >= width;
};
let startRow = row;
@@ -2385,6 +2529,16 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
/**
* Lowercase connectives and verbs that put a spaced candidate in a sentence, for
* {@link spacedFilePathOnLine}. `at` stays out (`Screenshot 2026-10-24 at 10.00.00.png`)
* and so does `of` (`Copy of report.docx`); a name using a listed word
* (`Pros and Cons.md`) splits as it did before spaced paths were linked.
*/
const SPACED_PATH_PROSE_WORD =
/^(?:and|or|to|in|into|from|the|is|are|was|were|has|have|had|contains|holds|see|then|vs|via|as)$/;
/** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */
const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`);
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
@@ -2508,6 +2662,395 @@ function terminalPathLine(buffer, row, cols, maxRows) {
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
/**
* Web URLs in terminal text, with the trimming the link provider applies.
*
* A single `&` is part of the URL (query separator); `&&` is the shell operator
* and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside
* it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an
* unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and
* `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed.
*
* @param {string} text
* @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>}
* `endIndex` is exclusive; `rawEnd` is where the match ran to before the
* paren/punctuation trim (the wrap-continuation test needs it).
*/
function findTerminalUrls(text) {
// Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it
// by name to prove it stays linear-time on hostile lines.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g;
const urls = [];
let match;
while ((match = urlPattern.exec(String(text || ''))) !== null) {
const raw = match[0];
let end = raw.length;
let depth = 0;
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '(') depth++;
else if (raw[i] === ')') {
if (depth === 0) {
end = i;
break;
}
depth--;
}
}
const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, '');
if (!/:\/\/[^/]/.test(url)) continue;
urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length });
}
return urls;
}
/**
* The row span a URL covers when the CLI wrapped it with its OWN newlines at a
* point short of the terminal width.
*
* {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI
* that word-wraps inside a table cell or a paragraph (codex's markdown tables,
* for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and
* indents the rest to the cell's column, so neither rule fires and each row
* linked a fragment. A row continues the URL above it only when ALL hold:
* - the URL fragment ends on a character a wrapper breaks after
* (`/ ? & = - _ % # + ~`); a following table column may be excluded;
* - the next row is indented, but no further right than the column the URL
* started in (a table cell or list item continuation, not unrelated text),
* and no column gap lies between its indent and the URL on the first row
* (that would be the next record of a table);
* - that row's first token looks like URL material (contains one of
* `% & = / ? . - _`), is not a bare number/version or list marker,
* and is not itself a new `scheme://`, a shell prompt (`user@host:`),
* a home path (`~/`), an email address, an HTTP status line, a label
* ending in `:`, a measurement or clock time (`45%`, `12.3ms`,
* `10:00:00`), or a `name=value` field under a URL ending in `/`;
* without `% & = / ?`, the token must finish its cell (row end or a
* gap of at least two spaces) and must not be code with a call paren
* or a word ending a sentence (`Done.`).
* The last rule is what stops prose such as "…/files/" + "then run it" from
* being glued into one link.
*
* Bounded by `maxRows`, like the logical line. Pure: reads rows only.
*
* @param {{getLine: (row: number) => any, length: number}} buffer
* @param {number} row 0-based absolute row being examined.
* @param {number} cols Terminal width.
* @param {number} [maxRows]
* @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null}
* `firstRowUrlEnd` is where the URL's cell ends on the first row when
* other table columns follow it and that row does not soft-wrap; null
* means the row is not part of a URL continued this way.
*/
function terminalUrlContinuationExtent(buffer, row, cols, maxRows) {
const bound = Math.max(1, maxRows || 12);
const here = terminalLogicalLine(buffer, row, cols, bound);
if (!here) return null;
const chainFrom = (start) => {
const first = terminalLogicalLine(buffer, start, cols, bound);
if (!first || first.startRow !== start) return null;
let text = first.text.replace(/\s+$/, '');
let endRow = first.endRow;
let urlStartCol = -1;
let firstRowUrlEnd = null;
let extended = false;
while (endRow - start + 1 < bound) {
const urls = findTerminalUrls(text);
const last = urls[urls.length - 1];
// Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL
// row (terminalLinkLine), so a column sitting on the soft-wrapped part of
// the URL's row would stay in the text and take the next row with it.
const trailingColumns =
first.startRow === first.endRow &&
endRow === first.endRow &&
last &&
last.rawEnd < text.length &&
/^ {2,}\S/.test(text.slice(last.rawEnd));
const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text;
if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break;
if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col;
const next = buffer.getLine(endRow + 1);
if (!next || next.isWrapped) break;
const nextText = next.translateToString(true) || '';
const indent = nextText.length - nextText.replace(/^\s+/, '').length;
const token = nextText.slice(indent).split(/\s/, 1)[0];
// A hard continuation is indented (a table cell, a list item, Claude's
// two-space paragraph indent); a column-0 row is a new line of output.
if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent)))
break;
// When other table columns follow the URL, the next row must align with
// the start of its cell. A new numbered row must not become URL text.
if (
trailingColumns &&
(!indent ||
first.text[indent - 1] !== ' ' ||
first.text[indent] === ' ' ||
/ {2,}/.test(first.text.slice(indent, last.startIndex)))
)
break;
// The next row must start inside the URL's own cell: a column gap between
// its indent and the URL on the first row means it opens an earlier
// column (the next record of a table), not the rest of the URL.
if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break;
// A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line
// are never the rest of a URL, however much URL punctuation they carry.
if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break;
// Nor is a token that is a whole email address.
if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break;
if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break;
// A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`);
// the rest of a URL does not.
if (token.endsWith(':')) break;
// After a `/`, a `name=value` token is the next assignment or log field
// (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`.
if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break;
// Nor is a measurement or a clock time: `45%` and `45%|####` progress,
// `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding).
if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break;
// Hyphens, underscores and dots also occur in prose. Without stronger
// URL punctuation, the token must finish its cell, not introduce a sentence.
const rest = nextText.slice(indent + token.length);
if (!/[%&=/?]/.test(token)) {
// Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a
// word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title
// (`Mercury_(planet)`) and a file name before its period
// (`install.html.`) still continue the URL.
if (/[^_]\(/.test(token)) break;
if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break;
if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break;
}
const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound);
if (!nextLogical) break;
if (trailingColumns) firstRowUrlEnd = last.rawEnd;
text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, '');
endRow = nextLogical.endRow;
extended = true;
}
return { startRow: start, endRow, extended, firstRowUrlEnd };
};
// Walk up through earlier logical lines: the nearest one whose URL chain
// reaches this row owns it.
let start = here.startRow;
while (start >= 0 && here.startRow - start < bound) {
const chain = chainFrom(start);
if (chain && chain.extended && chain.endRow >= here.startRow) {
return {
startRow: chain.startRow,
endRow: chain.endRow,
...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }),
};
}
if (start === 0) break;
const above = terminalLogicalLine(buffer, start - 1, cols, bound);
if (!above) break;
start = above.startRow;
}
return null;
}
/**
* The text the link provider scans for `row`: {@link terminalPathLine}, widened
* to the rows of a URL the CLI wrapped short of the edge
* ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine.
*
* Over a widened span every row is read trimmed (except one a soft wrap
* continues from, which fills the width), a HARD continuation's indent is
* dropped (the cell mapping keeps the cells), and the first row stops at the
* URL's cell when other table columns follow it, so those columns never sit
* between the URL's pieces. Those columns still link: they are moved after the
* stitched rows as a segment of their own, mapped back to their cells.
*
* @returns Same shape as {@link terminalPathLine}; null when the row does not exist.
*/
function terminalLinkLine(buffer, row, cols, maxRows) {
const base = terminalPathLine(buffer, row, cols, maxRows);
if (!base) return null;
const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows);
if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base;
const startRow = Math.min(extent.startRow, base.startRow);
const endRow = Math.max(extent.endRow, base.endRow);
const segments = [];
let text = '';
let columnsAfterUrl = '';
for (let r = startRow; r <= endRow; r++) {
const line = buffer.getLine(r);
if (!line) break;
const next = r < endRow ? buffer.getLine(r + 1) : null;
let rowText = line.translateToString(!(next && next.isWrapped)) || '';
if (r === extent.startRow && extent.firstRowUrlEnd != null) {
columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd);
rowText = rowText.slice(0, extent.firstRowUrlEnd);
}
let colStart = 0;
if (r > startRow && !line.isWrapped) {
colStart = rowText.length - rowText.replace(/^\s+/, '').length;
rowText = rowText.slice(colStart);
}
segments.push({ row: r, textStart: text.length, colStart });
text += rowText;
}
const lastRow = segments[segments.length - 1].row;
// The columns cut off the URL's first row (a path or date column of a table)
// are scanned after the stitched rows, so they keep their links on that row.
// They open with the cell gap, which keeps them apart from the URL; the one
// space before them belongs to the last row, so an end offset there still
// maps onto that row and never onto their first cell.
if (columnsAfterUrl.trim()) {
text += ' ';
segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd });
text += columnsAfterUrl;
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
}
return { row: startRow, col: offset };
};
// The cut row has two segments: the one starting at or left of the cell owns it.
const cellToOffset = (targetRow, targetCol) => {
let seg = null;
for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
};
return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
}
/**
* Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown
* tables), returned as one link per path with a segment per row.
*
* Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH-
* BLDMESH-243/before-overview.png) 243/after-overview.png)
*
* Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no
* extension, the second no absolute root), and a linear stitch of the rows
* cannot serve a two-column table: each column's continuation sits in the same
* next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left
* edge is where its text starts after the previous ≥2-space column gap, and the
* continuation is the token starting at exactly that column on the next row.
* A fragment qualifies only when it is the last thing in its cell on that row
* (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places
* a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over
* `config.yaml`) is a whole value, never the first piece of the next row's. A
* continuation row must be blank to the left of the cell (a wrapper wraps every
* column of one table row together), unless a path in a left column continued
* on it too: a label there (`Entry`, `│ Log │`) is the next record. Every
* continuation must pass pathContinuation and its cell must be no wider than the
* preceding cell's text, so independent listing rows and longer prose never
* displace links.
*
* Pure: reads rows only. Cells are taken one per string character (xterm rows
* with wide glyphs would shift columns; such rows simply fail the alignment).
*
* @param {{getLine: (row: number) => any, length: number}} buffer
* @param {number} row 0-based absolute row being examined.
* @param {number} [maxRows] Bound on rows one path may span.
* @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>}
* Paths with a segment on `row`; `endCol` is exclusive, columns 0-based.
*/
function terminalCellWrappedPaths(buffer, row, maxRows) {
const bound = Math.max(2, maxRows || 4);
const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g');
// Box-drawing verticals are cell borders (Claude Code draws tables with them).
const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch);
const rowText = (r) => {
const line = buffer.getLine(r);
return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null;
};
// A fragment is the last text in its cell on this row when the row ends, a
// column gap (≥2 spaces) follows it, or a cell border does.
const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end));
const fromStart = (startRow) => {
const found = [];
const text = rowText(startRow);
if (!text) return found;
rootPattern.lastIndex = 0;
let m;
while ((m = rootPattern.exec(text)) !== null) {
const start = m.index;
if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue;
let end = start;
while (end < text.length && isPathChar(text[end])) end++;
rootPattern.lastIndex = Math.max(end, start + 1);
if (!endsCell(text, end)) continue;
// Already a complete path on its own row: the ordinary detector owns it.
const alone = absoluteFilePathPattern().exec(text.slice(start, end));
if (alone && alone.index === 0 && alone[0].length === end - start) continue;
// The cell's left edge: just past the nearest ≥2-space gap or cell border
// (and the padding after it) before the path.
const before = text.slice(0, start);
let cellCol = before.length - before.replace(/^\s+/, '').length;
const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g;
let e;
const upToPath = text.slice(0, start + 1);
while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length;
let joined = text.slice(start, end);
let previousCellLength = end - cellCol;
const segments = [{ row: startRow, startCol: start, endCol: end }];
for (let r = startRow + 1; r < startRow + bound; r++) {
const line = buffer.getLine(r);
if (!line || line.isWrapped) break;
const next = rowText(r);
if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break;
let tokenEnd = cellCol;
while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++;
const token = next.slice(cellCol, tokenEnd);
// Include prose after the token in this cell, but not another column.
const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd();
// A wrapper breaks a path after `/` or `-`; a fragment that ends inside a
// name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece.
// codex wraps table cells on Unicode line-break opportunities plus a
// hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14
// class AL).
if (!/[/-]$/.test(joined)) break;
// A wrapper wraps every column of one table row together, so a row that
// carries a cell's continuation is blank to its left unless a path in a
// left column continued on that row too. A label there (`Entry`,
// `│ Log │`) opens the next record, even under a directory ending in `/`
// (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of
// wrapped prose (a `Description | Path` table) fills that row as well, so
// the path beside it is not joined and keeps its per-row fragment links:
// the safe direction.
// Known trade-off: with nothing to the left, a directory written with its
// trailing slash over a relative path in the same column (`/home/u/proj/`
// over `index.ts`) still joins, since it reads exactly like a wrap at `/`.
if (
/[^\s│┃║|]/.test(next.slice(0, cellCol)) &&
!found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol))
) {
break;
}
if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break;
const candidate = joined + token;
const match = absoluteFilePathPattern().exec(candidate);
if (match && match.index === 0 && match[0].length > joined.length) {
segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length });
found.push({ path: match[0], segments });
break;
}
if (!endsCell(next, tokenEnd)) break;
joined = candidate;
previousCellLength = cellText.length;
segments.push({ row: r, startCol: cellCol, endCol: tokenEnd });
}
}
return found;
};
const results = [];
for (let s = row; s >= 0 && row - s < bound; s--) {
for (const hit of fromStart(s)) {
if (hit.segments.some((seg) => seg.row === row)) results.push(hit);
}
}
return results;
}
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
@@ -3338,7 +3881,9 @@ function clampTerminalDimensions(proposed) {
* could reach it. Output visible, typing invisible, for as long as the claim
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
* decide how much is on screen at once, and keeping the local row count keeps
* the composer at the bottom of a viewport that scrolls.
* the composer at the bottom of a viewport that scrolls. The one deliberate
* screen taller than its box is this device's own rows held under the
* on-screen keyboard, which keyboardClipPx keeps the composer in view for.
*
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
* @param {{cols: number, rows: number}|null} pty - what the server just reported
@@ -3350,6 +3895,62 @@ function reconcilePtyGeometry(local, pty) {
return { adopt: true, cols: pty.cols };
}
/** Rows below the last content within which the cursor still counts (keyboardClipPx). */
const KEYBOARD_CLIP_CURSOR_REACH = 2;
/**
* How far to scroll the terminal SCREEN up while its rows are held under the
* on-screen keyboard (setting `keyboardHoldsRows`).
*
* Every PTY height change makes Claude Code clear the screen and reprint only
* the transcript tail that fits, so rows above that tail are lost from tmux and
* older ones stay in history as stale copies. The keyboard opening and closing
* was such a change each time. Instead the rows stay as they were and
* `.xterm-screen` (taller than its box now) is translated up by this many px.
*
* This is the one deliberate exception to "a screen taller than its container
* hides the input line" (see reconcilePtyGeometry): the offset below is what
* keeps the live content in view.
*
* - Following live output: the last row with content sits at the bottom of the
* box. A fresh shell prompting on row 1 shows the top; Claude's footer under
* its composer stays visible. The cursor counts only when it is right below
* that content (a prompt or composer line still empty): a TUI that parks its
* cursor far below what it drew (codex's dialogs, with the cursor on the last
* row of an otherwise blank screen) must not scroll its content out of view.
* - Reading history: the offset follows the scroll position, so the top of the
* scrollback is reachable and scrolling stays continuous.
*
* Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw
* frames ever make the view jitter.
*
* @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number,
* lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input
* lastContentRow: the last non-blank screen row, or -1 when the screen is blank
* @returns {number} px, between 0 and the screen's overflow
*/
function keyboardClipPx(input) {
const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input;
if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0;
const max = Math.max(0, rows * cellHeight - viewHeight);
if (max === 0) return 0;
if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY)));
const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH;
const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow;
const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight;
return Math.round(Math.min(max, Math.max(0, bottom - viewHeight)));
}
/**
* How many screen rows, from the top, are fully visible at a given offset: the
* local-echo overlay grows upward from the last of them (`setRowLimit`).
* @returns {number}
*/
function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) {
if (!(cellHeight > 0)) return 0;
return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight));
}
/**
* Which session does a dashboard URL's fragment ask for? Another page that
* holds the dashboard's window, such as a task board, points it at
@@ -3429,7 +4030,14 @@ if (typeof window !== 'undefined') {
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanTerminalLines = {
terminalLogicalLine,
terminalPathLine,
terminalLinkLine,
terminalUrlContinuationExtent,
findTerminalUrls,
terminalCellWrappedPaths,
};
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
@@ -3480,6 +4088,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
reconcilePtyGeometry,
keyboardClipPx,
keyboardVisibleRowLimit,
TERMINAL_MIN_COLS,
TERMINAL_MIN_ROWS,
};
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more