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
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
Aamer Akhter 27c93b20fb fix(terminal): stop URL continuations from swallowing prose 2026-10-23 10:00:38 -04:00
Aamer Akhter 74a107cf4f fix(terminal): require path labels and preserve markdown context 2026-10-23 09:01:44 -04: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
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
173 changed files with 19297 additions and 786 deletions

No files matched your search

@@ -1,6 +0,0 @@
---
"aicodeman": patch
"xterm-zerolag-input": patch
---
**Typing stays smooth in a long prompt.** The local echo overlay (the instant preview of what you type before the session echoes it back) used to throw away and rebuild every character of the prompt on each keystroke, so the longer the prompt, the slower each key. On a phone-class CPU it dropped below 60 fps at about 300 characters, and at about 4,000 characters a single keystroke took most of a second to appear. It now rebuilds only the line that changed, and each line is one paint layer instead of one layer per character, so a keystroke costs the same however long the prompt is. What you see is unchanged: every character still sits on its terminal cell, within 1/8 px. This ships in `xterm-zerolag-input` as well: the README's rendering section describes the new layout.
+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.45.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"
+38
View File
@@ -1,5 +1,43 @@
# 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
+14 -10
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.45.0 (must match `package.json`)
**Version**: 1.46.0 (must match `package.json`)
## Project Overview
@@ -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,7 +175,7 @@ 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)
@@ -189,7 +189,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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.
@@ -226,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)
@@ -234,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)
@@ -262,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)
@@ -281,7 +285,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **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 |
@@ -302,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`.
+25 -5
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)
@@ -584,6 +584,8 @@ loginctl enable-linger $USER
**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
@@ -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,14 +981,18 @@ 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
@@ -994,11 +1002,23 @@ codeman agent ack <msg-id> # remove what you hand
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
+6 -2
View File
@@ -585,6 +585,8 @@ loginctl enable-linger $USER
通过隧道暴露前**务必设置 `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>
### 二维码认证
@@ -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
+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.45.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.45.0",
"version": "1.46.0",
"transport": {
"type": "stdio"
},
+1
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',
+176 -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,9 +596,167 @@ 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|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 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`, 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`. 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 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`.
@@ -1152,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.
@@ -1175,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`.
@@ -1213,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. |
+111 -9
View File
@@ -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).
@@ -111,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.
@@ -205,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.
@@ -345,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).
@@ -409,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`.
@@ -750,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.
@@ -1025,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)
@@ -1151,7 +1157,9 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### 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
@@ -1182,7 +1190,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
| **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 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 `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 |
| **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 |
@@ -1260,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.
+14 -4
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
@@ -209,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)
@@ -585,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). |
+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.
+61 -1
View File
@@ -41,9 +41,13 @@ 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
@@ -53,6 +57,21 @@ codeman agent ack <msg-id> # remove what you hand
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
@@ -67,8 +86,49 @@ codeman agent rm "$SID" # any session except t
`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.
+6
View File
@@ -153,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.
+4 -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
+2 -1
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).
+33 -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. |
@@ -84,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*
@@ -106,6 +125,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha
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 |
@@ -121,6 +148,7 @@ memory. Messages expire 12 hours after they were posted either way. See
| 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. |
@@ -244,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. |
+8
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 |
+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.45.0",
"version": "1.46.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.45.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.45.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
+1
View File
@@ -206,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
+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",
@@ -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,
@@ -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.45.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.
+25 -6
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,21 +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`, and the inbox
verbs `post`, `inbox`, `ack`) 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.
@@ -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.
@@ -811,3 +813,16 @@ Rules that matter:
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.
+25 -6
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,21 +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`, and the inbox
verbs `post`, `inbox`, `ack`) 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.
+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) |
+18 -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.
@@ -811,3 +813,16 @@ Rules that matter:
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();
}
}
+310 -37
View File
@@ -2,16 +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`, and the
* agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). 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
@@ -23,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,
@@ -343,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) {
@@ -372,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));
}
@@ -509,7 +544,8 @@ export async function agentLs(deps: AgentDeps): Promise<number> {
}
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. */
@@ -532,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);
}
}
@@ -578,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) {
@@ -757,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. */
@@ -1038,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;
}
}
@@ -1048,7 +1272,7 @@ 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, and the inbox: post, inbox, ack'
'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
@@ -1059,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,
@@ -1169,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")
+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. */
+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 {
+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;
+352 -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';
@@ -457,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.
@@ -614,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. */
@@ -926,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`). */
@@ -1015,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
@@ -1308,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;
}
@@ -1412,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();
@@ -1443,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);
}
@@ -1579,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
@@ -2116,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,
@@ -4206,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'));
@@ -4715,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.
*
@@ -4734,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);
@@ -4782,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));
}
@@ -4803,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();
}
@@ -4898,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;
}
/**
@@ -4941,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
@@ -4970,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;
}
@@ -5092,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) {
@@ -5108,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) => {
+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;
}
+56 -9
View File
@@ -984,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 }
@@ -3983,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(
@@ -3996,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(
@@ -4050,7 +4065,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
`${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`);
}
@@ -4279,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 });
@@ -4317,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
@@ -4327,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
@@ -4396,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.
*
+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 */
+4 -2
View File
@@ -34,7 +34,7 @@ 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 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)
@@ -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)
+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>;
+155 -12
View File
@@ -2452,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) {
@@ -3048,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]);
}
@@ -5166,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
@@ -7141,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>' : ''}
@@ -8370,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;
@@ -8435,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;
@@ -8446,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');
@@ -8455,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();
@@ -8491,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') {
@@ -8505,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;
}
@@ -9703,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
+611 -12
View File
@@ -2165,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
@@ -2184,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
@@ -2344,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;
@@ -2396,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`. */
@@ -2519,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)
// ═══════════════════════════════════════════════════════════════
@@ -3349,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
@@ -3361,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
@@ -3440,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,
@@ -3491,6 +4088,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
reconcilePtyGeometry,
keyboardClipPx,
keyboardVisibleRowLimit,
TERMINAL_MIN_COLS,
TERMINAL_MIN_ROWS,
};
+88 -5
View File
@@ -9,9 +9,13 @@
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
* session changes or the window regains focus). The route is read-only and offline: it never fetches
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
* session changes or the window regains focus). The status route is read-only: it never changes the
* repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching
* happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`,
* src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel
* runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too,
* but only when the user asked for them at that moment). Remote (SSH)
* and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched.
*
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
* only ever written with `textContent`, never `innerHTML`.
@@ -265,6 +269,78 @@ Object.assign(CodemanApp.prototype, {
return this.refreshGitStatus({ fresh: true });
},
/**
* Fetch, on this click only: one `git fetch --quiet` per repository the panel shows
* (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the
* only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or
* touches the working tree, so an agent's own work is left alone, though a fetch does hold git's ref lock,
* so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One
* click means one fetch per repository: the button is busy and a second click is ignored while it runs, and
* a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the
* "behind" number reflects what this fetch brought.
*/
async fetchGitStatusNow() {
const sid = this.activeSessionId || null;
const btn = this.$('gitStatusFetchBtn');
if (!sid || !btn || this._gitFetchInFlight) return;
this._gitFetchInFlight = true;
this._renderGitFetchButton();
let report = null;
try {
const query = new URLSearchParams(this.gitStatusLimits());
report = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-fetch?${query}`, { method: 'POST' });
} catch {
report = null;
}
// A fetch that lands after a session switch is not shown for the session on screen now; the refs it
// moved are still on disk, so that session's own read reflects them.
if (sid === this.activeSessionId) this._gitStatusFetch = report ? { sessionId: sid, data: report } : null;
this._gitFetchInFlight = false;
await this.refreshGitStatus({ fresh: true });
},
/** The last Fetch this window ran for the session on screen, or null. */
_currentGitFetch() {
const f = this._gitStatusFetch;
return f && f.sessionId === this.activeSessionId && f.data ? f.data : null;
},
/** Busy while a fetch runs, and never enabled for a session there is nothing to fetch for. */
_renderGitFetchButton() {
const btn = this.$('gitStatusFetchBtn');
if (!btn) return;
const overview = this._currentGitStatus();
const fetchable = !!this.activeSessionId && !!overview && overview.state === 'ok';
btn.disabled = this._gitFetchInFlight || !fetchable;
if (this._gitFetchInFlight) btn.setAttribute('aria-busy', 'true');
else btn.removeAttribute('aria-busy');
},
/** What the last Fetch brought: a summary line, then one row per repository it could not fetch. */
_gitFetchNote() {
const report = this._currentGitFetch();
if (!report || report.state !== 'ok' || !report.repos?.length) return null;
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const failed = report.repos.filter((r) => !r.ok);
const count = report.repos.length;
const note = el('div', 'git-status-fetch-note');
note.append(
el(
'div',
'git-status-fetch-summary',
`Fetched ${new Date(report.fetchedAt).toLocaleTimeString()}: ${count - failed.length} of ${count} ${count === 1 ? 'repository' : 'repositories'}.`
)
);
for (const r of failed) {
const row = el('div', 'git-status-fetch-row');
row.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
row.append(el('span', 'git-status-repo-unreadable', `⚠ ${r.failure?.message || 'git fetch failed'}`));
note.append(row);
}
return note;
},
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
_ensureGitStatusPanelDrag() {
const panel = this.$('gitStatusPanel');
@@ -313,6 +389,7 @@ Object.assign(CodemanApp.prototype, {
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
this._renderGitFetchButton();
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
@@ -379,8 +456,13 @@ Object.assign(CodemanApp.prototype, {
}
}
const note = this._gitFetchNote();
if (note) body.append(note);
if (foot) {
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
// Runtime text (the panel footer carries data-i18n-skip), so naming the interval here does not fight the
// translator. It says what actually runs: this click, or the interval the user turned on.
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only apart from Fetch: Codeman fetches on your click, or on the interval you turned on in Settings, and never commits or changes the repository, so “behind” is as of the last fetch.`;
}
if (focusKey) {
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
@@ -449,7 +531,8 @@ Object.assign(CodemanApp.prototype, {
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
if (data.behind) {
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
behind.title = 'As of the last git fetch: Codeman never fetches.';
behind.title =
'As of the last git fetch. Click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.';
line.append(behind);
}
} else if (data.hasRemote) {
+45 -4
View File
@@ -107,6 +107,11 @@
'New session': '新建会话',
// The Cmd+K palette's new-session agent switch (panels-ui.js, its title tooltip).
'Agent for the new session (Left/Right to change)': '新会话使用的智能体(按左/右方向键切换)',
'New session here': '在此新建会话',
'New session here…': '在此新建会话…',
"New session in this tab's case": '在此标签页的案例中新建会话',
"This tab's folder is not a case, so there is nothing to start beside it.":
'此标签页的文件夹不是案例,无法在其旁边新建会话。',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
@@ -351,6 +356,7 @@
Delete: '删除',
Remove: '移除',
Edit: '编辑',
Fetch: '拉取',
Refresh: '刷新',
Back: '返回',
Next: '下一步',
@@ -483,6 +489,9 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
'New Session Button on Tabs': '标签页新建会话按钮',
'A + on the tab that starts a session in the same case and group, in any installed CLI.':
'标签页上的 + 按钮,可在同一案例和分组中,用任一已安装的 CLI 启动新会话。',
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
@@ -519,12 +528,13 @@
'Connection tile: offline': '离线',
'Connection tile: queued': '已排队',
'Connection tile: retry': '重连中',
// App Settings → Bottom bar, translated as one group (the Git status rows,
// #543's two included). Keys are the trimmed label text, without the scope tag.
// App Settings → Bottom bar, translated as one group (the Git status rows, #543's two included, and the
// two Git fetch rows, in the order the page lists them). Keys are the trimmed label text, without the
// scope tag. The Session Options → Git strings are in that group's own block further down.
'Bottom bar': '底部栏',
'Git status': 'Git 状态',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。',
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
@@ -534,6 +544,13 @@
'Git status: git timeout': 'Git 状态:git 超时',
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Git fetch on an interval': 'Git 定时拉取',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Git fetch: how often': 'Git 定时拉取:多久一次',
'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.':
'没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话(这里,或在“会话选项 → Git”)才会被拉取,共用一个文件夹的会话由一次运行拉取。',
'Fetch git status': '拉取 Git 状态',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
Panels: '面板',
@@ -667,6 +684,9 @@
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
'Local Echo': '本地回显',
'Keep Size Under Keyboard': '键盘弹出时保持尺寸',
"For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.":
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
'Gesture Control (beta)': '手势控制(测试版)',
@@ -697,6 +717,9 @@
'New sessions for this case land in this group': '此案例的新会话将放入此分组',
'Could not save the default session group.': '无法保存默认会话分组。',
'Remote auto-reconnect': '远程自动重连',
'Experimental agent coordination': '实验性智能体协作',
"Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.":
'允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。',
'Pin tab names on the CLI': '在 CLI 上固定标签名',
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
'以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
@@ -832,6 +855,24 @@
Memory: '内存',
GPUs: 'GPU',
// Session Options → Git (the tab's blurb, its group head, its hint and its three rows in index.html, plus
// the save toast session-ui.js raises). 'Status' is not a key here: that row's text is built at runtime
// by updateGitFetchStatus, and 'Git' stays a technical name.
"Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。',
'Fetch on an interval': '按间隔拉取',
"Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.":
'拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。',
'Follow the app setting': '跟随应用设置',
'Uses the app-wide switch and interval for this session. Switch it off to pick your own.':
'为此会话使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this session': '拉取此会话',
"Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.":
'即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。',
'How often': '多久一次',
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
'Git fetch settings saved': 'Git 定时拉取设置已保存',
// Cron / lifecycle / panels
'Cron Jobs': '定时任务',
'+ New Job': '+ 新建任务',
+92 -1
View File
@@ -625,6 +625,7 @@
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
<div class="git-status-actions">
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">&#x21BB;</button>
<button class="btn-icon-sm" id="gitStatusFetchBtn" onclick="app.fetchGitStatusNow()" title="Fetch" aria-label="Fetch git status">&#x2913;</button>
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">&times;</button>
</div>
</div>
@@ -1331,6 +1332,13 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><polyline points="7 14 11 10 14 13 20 7"/></svg>
<span>Summary</span>
</button>
<!-- Git is last: the section shows for every local session, but until the interval feature is
in use it holds one toggle and a status row that has never run, so it earns its place at the
end of the rail rather than among the tabs a session uses every day. -->
<button type="button" class="set-rail-item" data-tab="git" onclick="app.switchOptionsTab('git')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<span>Git</span>
</button>
</div>
<p class="set-rail-foot">These options belong to the selected session and save as you change them.</p>
</nav>
@@ -1719,6 +1727,54 @@
</div>
</div>
</section>
<!-- ══ Git ══════════════════════════════════════════════════════ -->
<section class="set-section hidden" id="git-tab" data-label="Git">
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<h2>Git</h2>
</div>
<p class="set-section-blurb">Whether Codeman fetches this session's repositories on a timer.</p>
<div class="set-group">
<div class="set-group-head"><h4>Fetch on an interval</h4></div>
<p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings &rarr; Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p>
<div class="set-group-body">
<!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the
run its folder got: several sessions on one folder share one run and read the same row. -->
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Status</span>
<span class="set-row-desc" id="gitFetchStatus" data-i18n-skip hidden></span>
</div>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Follow the app setting</span>
<span class="set-row-desc">Uses the app-wide switch and interval for this session. Switch it off to pick your own.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchFollow" checked onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
</div>
<div class="set-row" id="gitFetchEnabledRow">
<div class="set-row-text">
<span class="set-row-label">Fetch this session</span>
<span class="set-row-desc">Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchEnabled" onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
</div>
<div class="set-row" id="gitFetchIntervalRow">
<div class="set-row-text">
<span class="set-row-label">How often</span>
<span class="set-row-desc">30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.</span>
</div>
<div class="set-row-actions">
<input type="number" id="modalGitFetchInterval" class="set-num set-num-wide" value="300" min="30" max="3600" step="30" onchange="app.autoSaveGitFetch()">
<span class="set-unit">sec</span>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
@@ -1944,6 +2000,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLocalEcho"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="keyboard hold rows resize redraw mobile">
<div class="set-row-text">
<span class="set-row-label">Keep Size Under Keyboard</span>
<span class="set-row-desc">For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeyboardHoldsRows"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="cjk input ime chinese japanese korean">
<div class="set-row-text">
<span class="set-row-label">CJK Input</span>
@@ -2178,6 +2241,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentInboxPersist"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="experimental agent coordination inspect stage submit codeman agent guarded input">
<div class="set-row-text">
<span class="set-row-label">Experimental agent coordination</span>
<span class="set-row-desc">Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentCoordination"><span class="slider"></span></label>
</div>
</div>
</div>
@@ -2196,7 +2266,7 @@
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
<div class="set-row-text">
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
</div>
@@ -2221,6 +2291,20 @@
</div>
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1">
</div>
<div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically">
<div class="set-row-text">
<span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span>
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git fetch how often seconds between runs interval case override session options">
<div class="set-row-text">
<span class="set-row-label">Git fetch: how often <span class="set-scope">synced</span></span>
<span class="set-row-desc">Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.</span>
</div>
<input type="number" id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600" step="1">
</div>
</div>
</div>
@@ -2413,6 +2497,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="new session plus add tab same case group cli">
<div class="set-row-text">
<span class="set-row-label">New Session Button on Tabs</span>
<span class="set-row-desc">A + on the tab that starts a session in the same case and group, in any installed CLI.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabNewButton"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
<div class="set-row-text">
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
+22
View File
@@ -344,6 +344,13 @@ const KeyboardHandler = {
// branches are skipped on a shape change, whichever way the height moved.
if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
// Hold the PTY's rows for as long as the keyboard covers the terminal
// (keyboardHoldsRows, keyboardClipPx in constants.js): every height
// change makes Claude Code clear and reprint only the tail that fits.
// Captured before anything refits, so these are the keyboard-free rows.
if (typeof app !== 'undefined' && app.terminal && app._keyboardHoldEligible?.()) {
app._keyboardHeldRows = app.terminal.rows;
}
document.body.classList.add('keyboard-visible');
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
@@ -355,6 +362,7 @@ const KeyboardHandler = {
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) {
this.keyboardVisible = false;
this._releaseHeldRows();
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
@@ -381,6 +389,12 @@ const KeyboardHandler = {
// drop can re-arm the show branch against a baseline that already sits
// at the shrunk height.
if (shapeChanged) {
// Held rows belong to the display that is gone: release them and arm the
// settle, which refits and tells the PTY. Nothing else would: with the
// keyboard up throttledResize stands aside, and a rotation alone arms no
// settle, so xterm kept the held rows with no clip and the composer sat
// under the keyboard until it closed.
if (this._releaseHeldRows()) this._scheduleViewportSettle();
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
} else if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
@@ -394,6 +408,11 @@ const KeyboardHandler = {
this.lastViewportHeight = currentHeight;
},
/** End a keyboard row hold (app._releaseKeyboardHold); true when one was held. */
_releaseHeldRows() {
return typeof app !== 'undefined' && !!app._releaseKeyboardHold?.();
},
/** Update layout when keyboard shows/hides */
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
@@ -606,6 +625,9 @@ const KeyboardHandler = {
// geometry xterm will actually keep (issue #464).
app.syncTerminalGeometry?.();
if (this.keyboardVisible) this._shrinkPaddingToFit();
// Rows held: nothing re-renders on its own (the size did not change),
// so place the screen for the settled box here.
app._applyKeyboardClip?.();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
if (shouldRestoreScroll) {
+26 -1
View File
@@ -949,6 +949,29 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* The "+" is opt-in (showTabNewButton, per-device; html.tabs-show-new).
Switched on it joins gear + close on the active tab only, and the label
reserve above grows to clear all three (test/mobile-tab-tap-zones). */
.session-tab .tab-new {
display: none;
}
html.tabs-show-new .session-tab.active .tab-new {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
width: 28px;
height: 32px;
padding: 0;
opacity: 0.8;
}
html.tabs-show-new .session-tab.active .tab-name {
min-width: 68px;
}
/* Gear icon on active tab - tiny, subtle */
.session-tab.active .tab-gear {
display: inline-flex;
@@ -3427,7 +3450,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
@media (hover: none) and (pointer: coarse) {
/* Remove hover-dependent UI patterns */
.session-tab .tab-close,
.session-tab .tab-gear {
.session-tab .tab-gear,
.session-tab .tab-new {
opacity: 1;
width: auto;
padding: 0.15rem 0.25rem;
@@ -4127,6 +4151,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab {
Matches the (hover: none) block above, but has to be repeated here because
the phone block hides them on non-active tabs with (0,2,0). */
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear,
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-new,
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
display: inline-flex;
align-items: center;
+4 -4
View File
@@ -643,9 +643,9 @@ Object.assign(CodemanApp.prototype, {
/**
* Left/Right change the agent only while the new-session row is highlighted,
* and only when the key would not move the caret anyway (Left at the start of
* the query, Right at its end, no selection), so editing a typed case name
* keeps working.
* and the caret is at either query boundary, with no selection. Both arrows
* stay in picker navigation after typing a case name; inside the query they
* still edit text, as do modified arrows and selection changes.
*/
_commandPaletteArrowCyclesAgent(e) {
const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0];
@@ -657,7 +657,7 @@ Object.assign(CodemanApp.prototype, {
const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length;
const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start;
if (start !== end) return false;
return e.key === 'ArrowRight' ? end >= value.length : start <= 0;
return start <= 0 || end >= value.length;
},
_findCommandPaletteCaseMatch(query = '') {
+145 -23
View File
@@ -684,9 +684,12 @@ Object.assign(CodemanApp.prototype, {
/**
* Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity).
* `options` comes from the tab group menu's "New session": `count` overrides
* the instance steppers for this launch only, `tabGroupId` rides every create
* request so the server places the session in that group.
* `options` comes from the tab group menu's "New session", `_launchOnce` (a
* worktree, a tab's "+"): `count` overrides the instance steppers for this
* launch only, `tabGroupId` rides every create request so the server places the
* session in that group, `caseName` launches in that case instead of the
* toolbar's selected one, and `mode` starts that CLI instead of the Run
* button's mode, which is never touched.
*/
async run(options = {}) {
if (this._runInFlight) return;
@@ -701,7 +704,7 @@ Object.assign(CodemanApp.prototype, {
}
try {
const mode = this._runMode || 'claude';
const mode = options.mode || this._runMode || 'claude';
if (mode === 'shell') {
return await this.runShell(options);
}
@@ -820,7 +823,8 @@ Object.assign(CodemanApp.prototype, {
* never gated; both callers skip it before asking.
*
* Reads only what is already known and never starts a container probe: the
* Run menu starts one (_probeDockerCaseModes), and its answer lands in
* Run menu and a tab's "+" (openTabNewSessionMenu, app.js) start one
* (_probeDockerCaseModes), and its answer lands in
* _dockerCaseModes, which this reads. Until then (a probe still in flight)
* the stored availableModes decide, else nothing is gated.
*/
@@ -2014,11 +2018,28 @@ Object.assign(CodemanApp.prototype, {
}
},
/** The case a launch runs in: its worktree's, the one it names (a tab's "+"), else the toolbar's. */
_runCaseName(options) {
if (options.worktree) return options.worktree.caseName;
return options.caseName || document.getElementById('quickStartCase').value || 'testcase';
},
/**
* Re-reads the case list after a launch, keeping the toolbar on its own case.
* The no-argument reload would re-select the server's `lastUsedCase`, and a
* tab's "+" launching through quick-start sets that to the tab's case while the
* toolbar stays where it was, so every later launch (a worktree's, a plain Run,
* a group's "New session") would otherwise move the toolbar there.
*/
_reloadCasesAfterRun() {
this.loadQuickStartCases(document.getElementById('quickStartCase').value || null);
},
async runClaude(options = {}) {
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
@@ -2123,7 +2144,7 @@ Object.assign(CodemanApp.prototype, {
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
if (remoteIds[0]) {
await this.selectSession(remoteIds[0]);
this.loadQuickStartCases();
this._reloadCasesAfterRun();
}
this.terminal.focus();
return;
@@ -2202,7 +2223,7 @@ Object.assign(CodemanApp.prototype, {
// Auto-switch to the new session using selectSession (does proper refresh)
if (firstSessionId) {
await this.selectSession(firstSessionId);
this.loadQuickStartCases();
this._reloadCasesAfterRun();
}
this.terminal.focus();
@@ -2249,7 +2270,7 @@ Object.assign(CodemanApp.prototype, {
async runShell(options = {}) {
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
@@ -2450,7 +2471,7 @@ Object.assign(CodemanApp.prototype, {
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
@@ -2685,6 +2706,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalKeepWarmEnabled').checked =
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
this.updateKeepWarmStatus(sessionId);
// Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// what SSE calls when a scheduled run lands, so the rows stay current while the modal is open.
this.updateGitFetchStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -2741,6 +2765,12 @@ Object.assign(CodemanApp.prototype, {
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
// Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is
// never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes
// hidden the same way.
const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]');
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : '';
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
if (!isExternalCli) {
const ralphState = this.ralphStates.get(sessionId);
@@ -3022,6 +3052,101 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* The Git tab: the session's last scheduled run, and which values its rows show. A session with no choice of
* its own follows the app setting, so its switch and interval display the app's numbers and go inert (the
* same inert-row pattern as updateKeepWarmStatus above), because changing them here is what makes the session
* stop following. The app numbers come from the settings cache, so what this shows can be one settings load
* behind; the server decides from a fresh read, not from what the page displays.
*
* SSE calls this on every `session:updated` for the open session (app.js `_onSessionUpdated`), and a working
* session sends one on each working/idle change, so a control the user is in (`document.activeElement`) is
* left as it is: rewriting it put a half-typed interval back to the stored one before `onchange` could save it.
*/
updateGitFetchStatus(sessionId) {
const statusEl = document.getElementById('gitFetchStatus');
const followBox = document.getElementById('modalGitFetchFollow');
const box = document.getElementById('modalGitFetchEnabled');
const num = document.getElementById('modalGitFetchInterval');
if (!statusEl || !followBox || !box || !num || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
const app = this.loadAppSettingsFromStorage() || {};
// No choice of its own on either field means the session follows the app. A partial one (its own switch,
// the app's interval) means it does not, and the row that still follows shows the app's value, which is
// also the value the scheduler runs it with (a default here would show one number and fetch at another).
const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined;
const enabled = session.gitFetchEnabled ?? (app.gitFetchEnabled === true);
const interval = session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300;
const focused = document.activeElement;
if (followBox !== focused) followBox.checked = follows;
if (box !== focused) box.checked = enabled;
if (num !== focused) num.value = interval;
for (const el of [box, num]) {
el.closest('.set-row')?.classList.toggle('set-row-disabled', follows);
el.disabled = follows;
}
const run = session.gitFetch;
const parts = run
? [
`Last run ${this._gitFetchAgo(run.ranAt)}${run.trigger === 'interval' ? ' on the interval' : ' by click'}`,
run.state === 'ok' ? `${run.repos} repositories` : run.state,
run.failed ? `${run.failed} failed` : '',
]
: [follows ? 'Nothing fetched on a timer yet, and this session follows the app setting.' : 'Nothing fetched on a timer yet for this session.'];
statusEl.textContent = parts.filter(Boolean).join(' · ');
statusEl.hidden = false;
},
/** 'just now', '4m', '2h': the short stamps the tab rows use, measured from a run's start. */
_gitFetchAgo(ts) {
const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
return `${Math.round(seconds / 3600)}h`;
},
async autoSaveGitFetch() {
if (!this.editingSessionId) return;
const follow = document.getElementById('modalGitFetchFollow').checked;
const num = document.getElementById('modalGitFetchInterval');
// `null` on the route means "no choice of your own", which is what following the app setting is.
const body = follow
? { enabled: null, intervalSeconds: null }
: {
enabled: document.getElementById('modalGitFetchEnabled').checked,
intervalSeconds: Math.min(3600, Math.max(30, parseInt(num.value, 10) || 300)),
};
// Enter fires `onchange` with the field still focused, and updateGitFetchStatus leaves a focused field
// alone, so the clamped number is written back here or the field would keep showing what was typed.
if (!follow) num.value = body.intervalSeconds;
try {
// `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a
// save. `_apiJson` is the one that says whether the call went through, and null here means it did not.
const data = await this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, {
method: 'POST',
body,
});
if (!data) {
this.showToast('Failed to save Git fetch settings', 'error');
return;
}
const session = this.sessions.get(this.editingSessionId);
const saved = data.gitFetchSettings;
if (session && saved) {
session.gitFetchEnabled = saved.enabled ?? undefined;
session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined;
}
this.updateGitFetchStatus(this.editingSessionId);
this.showToast('Git fetch settings saved', 'success');
} catch (err) {
this.showToast('Failed to save Git fetch settings: ' + err.message, 'error');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
@@ -3160,6 +3285,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
document.getElementById('git-tab').classList.toggle('hidden', tabName !== 'git');
// A switched-to section starts at its own top, not at the scroll offset the
// previous one was left at.
@@ -5754,7 +5880,7 @@ Object.assign(CodemanApp.prototype, {
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
await this._launchOnce(mode, { worktree: { caseName, name: created.name, path: created.path } });
} finally {
if (btn) btn.disabled = false;
}
@@ -5766,26 +5892,22 @@ Object.assign(CodemanApp.prototype, {
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
await this._launchOnce(mode, { worktree: { caseName, name, path: wt.path } });
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
* Start ONE session of `mode` through run(), the Run button's own entry (its launch lock, its
* per-mode paths), outside the toolbar's choices: `runOptions` names a worktree, or a case and
* tab group (a tab's "+"). The mode rides the call (`run({ mode })`) rather than a swap of the
* Run button's mode, so a mode the user picks while the launch is pending is never overwritten.
*/
async _launchInWorktree(worktree, mode) {
async _launchOnce(mode, runOptions) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
if (runOptions.tabGroupId) this.toggleTabGroupCollapsed?.(runOptions.tabGroupId, false);
await this.run({ count: 1, ...runOptions, ...(mode ? { mode } : {}) });
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
+116 -20
View File
@@ -541,6 +541,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
// Agent inbox snapshot on disk: synced, default OFF.
document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true;
// Experimental agent coordination (inspect/stage/submit routes): synced, default OFF.
document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true;
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
// the Run-menu picker's generated entries and this settings panel's visibility;
// the endpoint list itself is server state, loaded on demand below.
@@ -602,6 +604,11 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
// Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS).
// Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list
// below and are not stripped from a save: a stripped key would never reach the scheduler.
document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;
document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -618,6 +625,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
// Default ON, so an absent key reads as enabled rather than as off.
document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
// overwrites the system clipboard on a gesture the user may have meant only as
// a way to read, so it is opt-in rather than a default anyone has to discover.
@@ -658,6 +667,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowTabDetachButton').checked =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
// The tab's "+" (applyHeaderVisibilitySettings): per-device, OFF unless switched on.
document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
@@ -2271,10 +2282,16 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
const gate = newEnabled ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false };
if (!gate.proceed) {
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
body: JSON.stringify({ tunnelEnabled: newEnabled, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
@@ -2582,11 +2599,67 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* End an App Settings save whose tunnel enable did not go through (confirm cancelled, or
* refused by the server): the WHOLE settings PUT is off, so turn the tunnel toggle back
* off in the UI and localStorage. Other settings persisted to localStorage still apply locally.
*/
_abandonTunnelEnable(settings, keepOpen) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
},
/** The one SECURITY WARNING prompt for an unauthenticated public tunnel. */
_askUnauthTunnel() {
return confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
},
/**
* #706: ask BEFORE a tunnel enable whenever the server has no login (no CODEMAN_PASSWORD,
* not multi-user), every time, whatever the server would accept. With
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL set the PUT is never refused, so the refusal
* handler below would never ask. The ONE gate for the welcome link, the tunnel panel and
* the App Settings save. A tunnel that is already running is not being turned on, and
* the server does not guard it either. When the status cannot be read, the PUT goes out
* without the acknowledgement and `_handleTunnelEnableRefusal` stays the fallback.
* @returns {Promise<{ proceed: boolean, acknowledge: boolean }>} proceed=false: cancelled,
* send nothing; acknowledge: send `acknowledgeUnauthTunnel: true` with the enable.
*/
async _confirmTunnelEnable() {
let status = null;
try {
const res = await fetch('/api/tunnel/status');
if (res.ok) {
const env = await res.json();
status = env?.success === true ? env.data : env;
}
} catch {
/* unknown: the 403 handler asks instead */
}
if (!status || status.running || status.loginConfigured !== false) return { proceed: true, acknowledge: false };
if (!this._askUnauthTunnel()) {
this.showToast('Tunnel not enabled', 'info');
return { proceed: false, acknowledge: false };
}
return { proceed: true, acknowledge: true };
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* CODEMAN_PASSWORD is set and the unauthenticated-tunnel opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast. Since #706 this is the
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
@@ -2612,14 +2685,7 @@ Object.assign(CodemanApp.prototype, {
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
const confirmed = confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
const confirmed = this._askUnauthTunnel();
if (!confirmed) {
this._dismissTunnelConnecting?.();
this.showToast('Tunnel not enabled', 'info');
@@ -2664,10 +2730,15 @@ Object.assign(CodemanApp.prototype, {
return;
}
try {
const gate = enable ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false };
if (!gate.proceed) {
this.closeTunnelPanel();
return;
}
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
body: JSON.stringify({ tunnelEnabled: enable, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
@@ -2929,6 +3000,7 @@ Object.assign(CodemanApp.prototype, {
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
...this._configBackupRetentionPayload(_prev),
agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked,
agentCoordinationEnabled: document.getElementById('appSettingsAgentCoordination').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2948,6 +3020,13 @@ Object.assign(CodemanApp.prototype, {
// Clamped here and again on the server; an empty or odd value falls back to the default.
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
// Clamped to the schema's bounds here too: a value outside them fails the whole save, and these keys
// must survive the strip below because the server's scheduler is what reads them.
gitFetchEnabled: document.getElementById('appSettingsGitFetchEnabled').checked,
gitFetchIntervalSeconds: Math.min(
3600,
Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300)
),
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -2958,6 +3037,7 @@ Object.assign(CodemanApp.prototype, {
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
@@ -2978,6 +3058,7 @@ Object.assign(CodemanApp.prototype, {
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -3050,6 +3131,8 @@ Object.assign(CodemanApp.prototype, {
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
// Turning Keep Size Under Keyboard off while it holds releases it now.
this._reconcileKeyboardHold?.();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
this.applyTerminalFontWeights?.(settings);
@@ -3202,6 +3285,9 @@ Object.assign(CodemanApp.prototype, {
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
const {
localEchoEnabled: _leo,
// Per-device (it is about this screen's keyboard) and absent from
// SettingsUpdateSchema (.strict()), so sending it would 400 the whole PUT.
keyboardHoldsRows: _khr,
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
@@ -3249,6 +3335,7 @@ Object.assign(CodemanApp.prototype, {
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
showTabDetachButton: _tdb,
showTabNewButton: _tnb,
// Detach Tiles: what a tile drag does is a property of this device's
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
tileDetachEnabled: _tde,
@@ -3270,11 +3357,21 @@ Object.assign(CodemanApp.prototype, {
let webhookError = '';
let serverSaved = false;
try {
// #706: a save that turns the tunnel on asks first when the server has no login
// (one already running is not asked about, see _confirmTunnelEnable).
const tunnelGate = settings.tunnelEnabled && !settingsReadOnly
? await this._confirmTunnelEnable()
: { proceed: true, acknowledge: false };
if (!tunnelGate.proceed) {
this._abandonTunnelEnable(settings, keepOpen);
return;
}
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
@@ -3292,14 +3389,9 @@ Object.assign(CodemanApp.prototype, {
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
// so it doesn't look enabled.
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
this._abandonTunnelEnable(settings, keepOpen);
return;
}
@@ -4387,6 +4479,9 @@ Object.assign(CodemanApp.prototype, {
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
// The tab's "+" (new session beside it): per-device and opt-in everywhere,
// like the pop-out button. On a phone it also widens the active tab (mobile.css).
document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
@@ -4896,7 +4991,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
@@ -4904,6 +4999,7 @@ Object.assign(CodemanApp.prototype, {
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'showTabNewButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
+95 -2
View File
@@ -2992,6 +2992,33 @@ html[data-header-stats] .header-system-stats .stat-item[hidden] {
transform: rotate(45deg);
}
/* "+" (new session beside this tab, app.openTabNewSessionMenu): same reveal as the gear. */
.session-tab .tab-new {
opacity: 0;
width: 0;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
overflow: hidden;
transition: opacity 0.15s, width 0.15s, padding 0.15s;
}
.session-tab.active .tab-new {
opacity: 1;
width: auto;
padding: 0 0.3rem;
}
.session-tab .tab-new:hover {
color: var(--accent-hover);
}
/* Per-device toggle (App Settings → Tab Bar), mirrored as tabs-show-new on <html>
by settings-ui.js. !important so no layout's reveal rule (sidebar, touch) wins. */
html:not(.tabs-show-new) .session-tab .tab-new {
display: none !important;
}
/* ===== Session detach / undock (beta/session-detach) ===================== */
/* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */
@@ -3145,7 +3172,7 @@ html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
pointer-events: auto;
}
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-new, .tab-detach, .tab-close) {
display: none;
}
@@ -19865,6 +19892,7 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .ta
actions permanently visible on the active session — no layout jitter when
the pointer crosses the list. */
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-new,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close {
opacity: 1;
@@ -20379,6 +20407,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .se
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-new,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close {
display: none;
@@ -22148,6 +22177,42 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
gap: 0.25rem;
}
/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session
there is nothing to fetch for (no repository, remote or Docker). Scoped to the Git panel's header: the
File Viewer's Back/Forward and the folder grid's Up button are .btn-icon-sm too, and keep their own look. */
.git-status-actions .btn-icon-sm[disabled] {
color: var(--text-dim);
opacity: 0.45;
cursor: default;
}
.git-status-actions .btn-icon-sm[disabled]:hover {
background: none;
}
.git-status-actions .btn-icon-sm[aria-busy='true'] {
color: var(--text);
animation: btn-icon-busy 1.1s ease-in-out infinite;
}
@keyframes btn-icon-busy {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 0.9;
}
}
/* No pulse under reduced motion: the button holds the pulse's brighter step, still dimmer than at rest. */
@media (prefers-reduced-motion: reduce) {
.git-status-actions .btn-icon-sm[aria-busy='true'] {
animation: none;
opacity: 0.7;
}
}
.git-status-body {
flex: 1;
overflow-y: auto;
@@ -22167,7 +22232,8 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
.git-status-empty,
.git-status-ok,
.git-status-note,
.git-status-more {
.git-status-more,
.git-status-fetch-note {
color: var(--text-muted);
padding: 0.25rem 0;
}
@@ -22176,6 +22242,24 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
color: rgb(34, 197, 94);
}
/* The last Fetch's result: a summary line, then a row per repository it could not fetch. */
.git-status-fetch-note {
margin-top: 0.6rem;
padding-top: 0.4rem;
border-top: 1px solid var(--border);
}
.git-status-fetch-summary {
margin-bottom: 0.2rem;
}
.git-status-fetch-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.1rem 0;
}
.git-status-branchline {
display: flex;
flex-wrap: wrap;
@@ -23195,3 +23279,12 @@ html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
border: 1px solid var(--control-border);
color: var(--text-muted);
}
/* Rows held under the on-screen keyboard (keyboardHoldsRows; keyboardClipPx in
constants.js): the screen stays taller than its box and is scrolled up by
--kb-clip, set on #terminalContainer by _applyKeyboardClip (terminal-ui.js).
The helper textarea, IME preview, link layer and both echo overlays live
inside .xterm-screen, so they move with it, and every hit test reads rects. */
body.keyboard-visible .terminal-container .xterm-screen {
transform: translateY(calc(-1 * var(--kb-clip, 0px)));
}
+7
View File
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
event.preventDefault();
event.stopPropagation();
this.closeTabRailActionMenu();
this.closeTabGroupMenu?.();
const trigger = event.currentTarget;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu';
@@ -327,6 +328,12 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// The row's "+" (showTabNewButton on) as a menu item: a keyboard route beside
// Enter or Space on the "+" itself, and its only route in the compact rail,
// which hides it. Session rows only: tiles share this menu and carry no "+".
...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab')
? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }]
: []),
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
+2
View File
@@ -244,6 +244,8 @@
scrollback: this.scrollback ?? DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
// OSC 8 hyperlinks open through the primary pane's opener (terminal-ui.js).
linkHandler: global.app?._terminalOsc8LinkHandler?.(),
});
this.fitAddon = new FitAddon.FitAddon();
+286 -82
View File
@@ -767,6 +767,7 @@ Object.assign(CodemanApp.prototype, {
scrollback: scrollback,
allowTransparency: true,
allowProposedApi: true,
linkHandler: this._terminalOsc8LinkHandler(),
});
this.fitAddon = new FitAddon.FitAddon();
@@ -1060,6 +1061,9 @@ Object.assign(CodemanApp.prototype, {
if (MobileDetection.isTouchDevice()) {
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
// Keep the live content above the keyboard while the rows are held.
this.terminal.onCursorMove(() => this._applyKeyboardClip());
this.terminal.onRender(() => this._applyKeyboardClip());
}
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
@@ -2208,8 +2212,11 @@ Object.assign(CodemanApp.prototype, {
// links as the one file it is.
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
// `terminalLinkLine` widens that once more to the rows of a URL the CLI
// wrapped short of the edge (a table cell or an indented paragraph),
// where nothing marks the break and no row is full width.
const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalPathLine(
const logical = window.CodemanTerminalLines?.terminalLinkLine(
buffer,
bufferLineNumber - 1,
terminal.cols,
@@ -2227,8 +2234,16 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 };
};
// Paths a CLI word-wrapped inside a table cell (terminalCellWrappedPaths,
// constants.js). They follow their own cell down the rows, which the
// linear stitch above cannot do for a multi-column table, so they are
// found from the buffer directly and claim their cells over the rest.
const cellWrappedPaths = (
window.CodemanTerminalLines?.terminalCellWrappedPaths?.(buffer, bufferLineNumber - 1, 4) ?? []
).filter((hit) => !/(^|\/)\.\.(\/|$)/.test(hit.path));
// A path needs a `/` or an extension's `.`; a line with neither has no link.
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
if (!lineText || (!lineText.includes('/') && !lineText.includes('.') && cellWrappedPaths.length === 0)) {
callback(undefined);
return;
}
@@ -2240,44 +2255,46 @@ Object.assign(CodemanApp.prototype, {
const taken = [];
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority
//
// A single `&` is PART of the URL: it separates query parameters, so excluding
// it truncated every real query string (`?post=1479&action=edit` linked only
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
// that is the shell operator and never appears inside a URL. A lone trailing
// `&` is trimmed below with the other trailing punctuation.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
const addUrlLink = (url, matchIndex) => {
// Strip trailing punctuation that's likely not part of the URL
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
const startCol = lineText.indexOf(cleaned, matchIndex);
if (startCol === -1) return;
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority.
// The grammar (balanced parens, `&` vs `&&`, trailing punctuation) is
// findTerminalUrls() in constants.js, which the wrap-continuation extent
// above reads URLs with too, so the two cannot disagree.
const addUrlLink = ({ url, startIndex }) => {
const start = coordAt(startIndex);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
taken.push({ start: startCol, end: startCol + cleaned.length });
taken.push({ start: startIndex, end: startIndex + url.length });
links.push({
text: cleaned,
range: { start, end },
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
// A `localhost` link tapped from another device can only work
// through the server: route it into a proxied web tab
// (webview-tabs.js). Anything else opens as before.
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
// One link per terminal row the URL occupies, each opening the WHOLE
// URL. A single range spanning rows would also underline (and make
// clickable) the indent and table columns between the pieces of a
// URL the CLI wrapped. A single-row URL keeps its original range.
const endIndex = startIndex + url.length;
const pieces = [];
let pieceStart = startIndex;
for (let i = startIndex + 1; i < endIndex; i++) {
if (coordAt(i).y !== coordAt(i - 1).y) {
pieces.push([pieceStart, i]);
pieceStart = i;
}
}
pieces.push([pieceStart, endIndex]);
for (let n = 0; n < pieces.length; n++) {
const [from, to] = pieces[n];
links.push({
text: url,
range: { start: coordAt(from), end: pieces.length === 1 ? coordAt(to) : coordAt(to - 1) },
decorations: { pointerCursor: true, underline: true },
activate() {
self._openTerminalUrl(url);
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
}
};
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
@@ -2300,6 +2317,41 @@ Object.assign(CodemanApp.prototype, {
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const activateFilePath = (text, folder = false) => {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
};
const addLink = (filePath, matchIndex, folder = false) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
@@ -2318,43 +2370,8 @@ Object.assign(CodemanApp.prototype, {
pointerCursor: true,
underline: true,
},
activate(event, text) {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
activate(_event, text) {
activateFilePath(text, folder);
},
hover() {
setHovered(true);
@@ -2368,9 +2385,8 @@ Object.assign(CodemanApp.prototype, {
// Match all patterns — URLs first so they take priority
let match;
urlPattern.lastIndex = 0;
while ((match = urlPattern.exec(lineText)) !== null) {
addUrlLink(match[0], match.index);
for (const reference of window.CodemanTerminalLines?.findTerminalUrls?.(lineText) ?? []) {
addUrlLink(reference);
}
cmdPattern.lastIndex = 0;
@@ -2387,6 +2403,54 @@ Object.assign(CodemanApp.prototype, {
addLink(match[1], match.index);
}
// One link per row segment, each opening the WHOLE path (as for a wrapped
// URL). A fragment of the same cells linked by another detector (a
// relative `243/after-overview.png`, say) would open the wrong file, so
// the cell-wrapped path displaces it.
// A path the line stitch already joined whole (a single-column wrap, which
// terminalPathLine handles) keeps its one link.
const cellOnlyPaths = cellWrappedPaths.filter(
(hit) =>
!links.some(
(l) =>
l.text === hit.path &&
l.range.start.y === hit.segments[0].row + 1 &&
l.range.start.x === hit.segments[0].startCol + 1
)
);
if (cellOnlyPaths.length > 0) {
const claimed = cellOnlyPaths.flatMap((hit) => hit.segments);
const overlapsClaim = (link) =>
claimed.some((seg) => {
const y = seg.row + 1;
if (y < link.range.start.y || y > link.range.end.y) return false;
const from = y === link.range.start.y ? link.range.start.x : 1;
const to = y === link.range.end.y ? link.range.end.x : Number.MAX_SAFE_INTEGER;
return from <= seg.endCol && to >= seg.startCol + 1;
});
for (let i = links.length - 1; i >= 0; i--) {
if (overlapsClaim(links[i])) links.splice(i, 1);
}
for (const hit of cellOnlyPaths) {
for (const seg of hit.segments) {
links.push({
text: hit.path,
range: { start: { x: seg.startCol + 1, y: seg.row + 1 }, end: { x: seg.endCol, y: seg.row + 1 } },
decorations: { pointerCursor: true, underline: true },
activate() {
activateFilePath(hit.path);
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
}
}
}
if (links.length > 0) {
console.debug(
'[LinkProvider] Found links:',
@@ -2409,6 +2473,30 @@ Object.assign(CodemanApp.prototype, {
return provider;
},
/**
* xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links),
* which take precedence over the text link provider. Routed through the same
* opener after confirming the real target (the label may hide it), and only
* for web URLs: any other scheme a program sends is ignored.
*/
_terminalOsc8LinkHandler() {
return {
activate: (_event, text) => {
if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return;
if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text);
},
};
},
/** Open a URL clicked in a terminal (text link or OSC 8). */
_openTerminalUrl(text) {
// A `localhost` link tapped from another device can only work through the
// server: route it into a proxied web tab (webview-tabs.js). Anything else
// opens in a new tab.
if (this.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
/**
* The terminal link under a viewport point, or null.
*
@@ -6556,12 +6644,124 @@ Object.assign(CodemanApp.prototype, {
// timer and the resize observer, where an exception takes the rest of the
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
try {
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
const dims = window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
// Rows held under the on-screen keyboard (see keyboardClipPx): the
// container shrank, the PTY must not. Columns still follow the container.
const held = this._keyboardHeldRowsActive?.();
if (dims && held) dims.rows = Math.max(dims.rows, held);
return dims;
} catch {
return null;
}
},
/**
* Rows held while the on-screen keyboard is up, or 0. Set by KeyboardHandler
* on the show transition when `keyboardHoldsRows` is on (default), cleared on
* hide and on a shape change. Every geometry read goes through
* getTerminalDimensions(), so holding it there holds the fit, the PTY resize
* and every capture-geometry comparison alike.
* @returns {number}
*/
_keyboardHeldRowsActive() {
if (!(this._keyboardHeldRows > 0)) return 0;
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible ? this._keyboardHeldRows : 0;
},
/**
* May the keyboard hold this session's rows? Only an INLINE renderer (the
* `keyboardHold` capability, read from `window.__codemanKeyboardHoldModes`,
* never an id literal here): a CLI that lays out the full height (codex) cannot
* show both its content and its composer above the keyboard and must be
* resized, as before. Never while the CLI reports mouse tracking (Claude's
* fullscreen mode), and never for a remote or docker session, whose pane hosts
* a nested client. Absent data means no hold. Per-device setting
* `keyboardHoldsRows`, default ON.
*/
_keyboardHoldEligible(sessionId = this.activeSessionId) {
if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false;
const session = sessionId ? this.sessions?.get(sessionId) : null;
if (!session || session.remote || session.docker || session.cliMouseTracking) return false;
const modes = window.__codemanKeyboardHoldModes;
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
},
/**
* Drop a hold the active session no longer qualifies for (switched to a CLI
* that resizes, entered fullscreen, setting turned off) and, unless the caller
* resizes itself (selectSession), fit and tell the PTY at once, which is the
* old keyboard behavior. Never re-holds: a session that becomes eligible with
* the keyboard already up keeps the short rows until the keyboard closes.
*/
_reconcileKeyboardHold({ resize = true } = {}) {
if (!this._keyboardHeldRows || this._keyboardHoldEligible()) return;
this._releaseKeyboardHold();
if (resize && this.activeSessionId) this.sendResize(this.activeSessionId)?.catch?.(() => {});
},
/**
* End a keyboard row hold and put the screen back in place (no offset, no
* overlay bound). Shared by KeyboardHandler (keyboard hide, shape change)
* and _reconcileKeyboardHold.
* @returns {boolean} whether a hold was released
*/
_releaseKeyboardHold() {
if (!this._keyboardHeldRows) return false;
this._keyboardHeldRows = 0;
this._applyKeyboardClip();
return true;
},
/**
* Scroll the (held, taller than its box) terminal screen so the live content
* sits above the keyboard, and bound the local-echo overlay to the rows that
* are visible. Runs on every render and cursor move while the hold is active;
* resets once when it ends.
*/
_applyKeyboardClip() {
const container = this.terminal?.element?.parentElement;
if (!container) return;
const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0;
const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0;
const viewHeight = rows ? container.clientHeight : 0;
let clip = 0;
if (cellHeight && viewHeight) {
const buffer = this.terminal.buffer.active;
const following = buffer.viewportY >= buffer.baseY;
let lastContentRow = -1;
if (following) {
for (let row = rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true).trim()) {
lastContentRow = row;
break;
}
}
}
clip = window.CodemanTerminalGeometry.keyboardClipPx({
rows,
cellHeight,
viewHeight,
cursorRow: buffer.cursorY,
lastContentRow,
following,
viewportY: buffer.viewportY,
baseY: buffer.baseY,
});
}
if (clip !== (this._keyboardClipPx || 0)) {
this._keyboardClipPx = clip;
if (clip) container.style.setProperty('--kb-clip', `${clip}px`);
else container.style.removeProperty('--kb-clip');
}
// Bounded whenever rows are held, even at offset 0 (a prompt near the top
// still has held rows below it, under the keyboard). setRowLimit dedupes.
this._localEchoOverlay?.setRowLimit?.(
cellHeight && viewHeight
? window.CodemanTerminalGeometry.keyboardVisibleRowLimit(clip, viewHeight, cellHeight)
: null
);
},
/**
* Fit xterm to its container and return the geometry that was APPLIED.
*
@@ -6589,7 +6789,10 @@ Object.assign(CodemanApp.prototype, {
syncTerminalGeometry() {
if (!this.fitAddon || !this.terminal) return null;
try {
this.fitAddon.fit();
// fit() would shrink xterm to the keyboard-shortened box before the held
// rows put it back: skip it while they are held (_resizeTerminalTo below
// still applies the container's columns).
if (!this._keyboardHeldRowsActive?.()) this.fitAddon.fit();
} catch {
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
}
@@ -6614,8 +6817,9 @@ Object.assign(CodemanApp.prototype, {
* part of the scrollback in between. The mobile retry asks every 30 seconds,
* so that happened on a timer for as long as the refusal lasted. So the
* columns stay at the PTY's (the #464 invariant: the browser never draws at
* a width the PTY does not have), the rows follow the container (they are
* never adopted, see reconcilePtyGeometry), and the container's columns go
* a width the PTY does not have), the rows follow the container or the
* keyboard hold (they are never adopted, see reconcilePtyGeometry and
* getTerminalDimensions), and the container's columns go
* out as the request. An accepted request is adopted by the report.
*
* @returns {{cols: number, rows: number}|null} the geometry to request
+9 -9
View File
@@ -1,13 +1,13 @@
{
"version": "1.45.0",
"version": "1.46.0",
"highlights": [
"Rename a case and its folder (#680)",
"A per-session agent inbox (#690)",
"Pick the agent from Cmd+K (#685)",
"A resizable header toolbar (#689)",
"GPU and CPU clock readings (#692, opt-in)",
"A tunnel no longer locks everyone out after ten typos (#684, multi-user mode)",
"The first wheel notch moves a Codex transcript (#699)",
"The wheel no longer moves a Codex menu's selection (#698)"
"Fetch from the Git status window (#714)",
"Start a session beside any tab (#712, opt-in)",
"The phone keyboard no longer costs Claude its transcript (#711)",
"Typing stays smooth in a long prompt (#713)",
"Links survive wrapping and spaces (#686, #687)",
"`codeman agent spawn` in a project directory (#708)",
"Read saved Codex conversations offline (#707)",
"Experimental: bounded inspection and guarded submission for agents (#694, opt-in)"
]
}
+193
View File
@@ -0,0 +1,193 @@
/**
* @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints.
* Ownership is checked before any disk or pane read. Native evidence comes only
* from strict conversation attribution; all other modes get labeled snapshots.
*
* Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the
* setting, until the synced `agentCoordinationEnabled` is on (default OFF), the
* same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first,
* before session lookup, so a disabled install reveals nothing about its sessions.
*/
import type { FastifyInstance } from 'fastify';
import type { Session } from '../../session.js';
import type { SessionPort, InfraPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js';
import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js';
import {
GuardedStageStore,
GuardedInputConflict,
guardedComposerChrome,
MAY_BE_TYPED,
type GuardedInputTarget,
} from '../../agent-staging.js';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js';
/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */
export async function readAgentCoordinationEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.agentCoordinationEnabled === true;
}
/** The refusal every route sends while the setting is off. Exported for the tests. */
export const AGENT_COORDINATION_DISABLED_MESSAGE =
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
async function gate(): Promise<ApiResponse<never> | null> {
if (await readAgentCoordinationEnabled()) return null;
return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE);
}
/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */
export function errorCode(error: unknown): string {
const code = (error as { code?: unknown } | null)?.code;
if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code;
return error instanceof Error ? error.constructor.name : 'unknown';
}
export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
const reader = new InspectionReader();
const stages = new GuardedStageStore();
app.addHook('onClose', async () => {
reader.clear();
stages.clear();
});
const generation = (session: Session) => session.inspectionGeneration;
const target = (session: Session): GuardedInputTarget => ({
id: session.id,
chrome: () => guardedComposerChrome(getCli(session.mode)),
snapshot: () => {
if (ctx.sessions.get(session.id) !== session) return null;
const snapshot = session.guardedInputSnapshot?.();
return snapshot ?? null;
},
write: (text) => session.stageGuardedInput(text),
submit: (expected) => session.submitGuardedInput(expected),
});
app.get('/api/sessions/:id/inspect', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query);
const initialGeneration = generation(session);
const identity = { sessionId: session.id, mode: session.mode };
const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() };
try {
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) {
const conversation = await findExactCodexConversation(
{
codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir),
sessionId: session.id,
workingDir: session.workingDir,
resumeSessionId: session.codexConfig?.resumeSessionId,
},
{ maxEntries: 8192, maxBytes: 1048576 }
);
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
throw new InspectionConflict('Session changed during inspection');
if (conversation) {
const result = await reader.read(
conversation.path,
{
owner: getAuthUser(req).username,
sessionId: id,
generation: initialGeneration,
conversationId: conversation.id,
},
maxBytes,
since
);
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
throw new InspectionConflict('Session changed during inspection');
return {
success: true,
data: {
source: 'codex-jsonl',
identity: { ...identity, conversationId: conversation.id },
activity,
incremental: true,
maxBytes,
...result,
},
};
}
}
if (since)
throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot');
const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null;
const evidence = frame ?? session.terminalBuffer;
return {
success: true,
data: {
source: 'pane-snapshot',
identity,
activity,
incremental: false,
cursor: null,
maxBytes,
evidence: boundedUtf8Tail(evidence, maxBytes),
truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes,
},
};
} catch (error) {
if (error instanceof InspectionConflict)
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message));
// Anything else (an unreadable rollout, an IO error) is not a retryable cursor
// conflict. Log the session and the error code only: the message can carry the
// native transcript path, which must never reach the log or the response.
console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`);
return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable'));
}
});
app.post('/api/sessions/:id/stage', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { message } = parseBody(AgentStageSchema, req.body);
try {
const receipt = await stages.stage(target(session), getAuthUser(req).username, message);
return { success: true, data: receipt };
} catch (error) {
if (!(error instanceof GuardedInputConflict))
console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`);
return reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
error instanceof GuardedInputConflict
? error.message
: `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}`
)
);
}
});
app.post('/api/sessions/:id/submit', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body);
try {
return {
success: true,
data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha),
};
} catch (error) {
if (!(error instanceof GuardedInputConflict))
console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`);
return reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
error instanceof GuardedInputConflict
? error.message
: 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane'
)
);
}
});
}
+33 -32
View File
@@ -78,6 +78,12 @@ import {
parseBody,
readJsonConfig,
} from '../route-helpers.js';
import {
assertSettingsWritable,
mutateSettingsFile,
SettingsWriteRefusedError,
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -1849,6 +1855,17 @@ export function registerCaseRoutes(
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
// The admin's rename writes settings.json only after the sessions are ended and the folder moved, and
// the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no
// session is ended for a rename that cannot complete. Not between the starting check and the lock below.
if (isAdmin(req)) {
try {
await assertSettingsWritable();
} catch (err) {
if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message);
throw err;
}
}
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
@@ -1876,27 +1893,23 @@ export function registerCaseRoutes(
}
if (moveFolder) await moveCaseFolder(oldPath, newPath);
const originalLinks = isLinked ? await readLinkedCases() : undefined;
// The settings.json content before this rename wrote it, set only once a write is attempted (an
// absent file reads as {}, which must not be written back as a file that never existed).
let originalSettings: Record<string, unknown> | undefined;
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
try {
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
// Written before linked-cases.json so a failure here leaves the registry untouched.
if (isAdmin(req)) {
const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (order || last) {
originalSettings = existing;
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(
{ ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) },
null,
2
)
);
const { before, after } = await mutateSettingsFile((existing) => {
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (!order && !last) return undefined;
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
if (originalLinks) {
@@ -1913,9 +1926,8 @@ export function registerCaseRoutes(
} catch (err) {
// The folder moved but the registries did not follow: put settings.json and the folder back
// rather than leave a case whose name and folder disagree.
if (originalSettings) {
await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {});
}
const undo = settingsUndo;
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
@@ -1970,22 +1982,11 @@ export function registerCaseRoutes(
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
const dir = getDataDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
const merged = { ...existing, caseOrder: order };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
await mutateSettingsFile((current) => ({ ...current, caseOrder: order }));
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
return { success: true, data: { order } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
+2 -2
View File
@@ -128,8 +128,8 @@ export async function runConfigBackup(
reason: string,
opts: { force?: boolean } = {}
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read
// that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before
// settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30
// days) and drop the longer history the user asked to keep.
const raw = await readSettingsForBackup();
const unreadable = raw === 'unreadable';
+83 -14
View File
@@ -1,21 +1,25 @@
/**
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
* @fileoverview The Git panel's routes: `GET /api/sessions/:id/git-status` (what the session's workspace
* has not committed or pushed, src/git-workspace-status.ts) and `POST /api/sessions/:id/git-fetch` (the
* panel's Fetch button, src/git-fetch.ts), for the bottom-bar Git indicator and its window. The answer is
* an overview: the enclosing repository, or each repository found below a folder that holds several
* projects (see `getGitWorkspaceOverview` for exactly which).
*
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
* Read-only and offline: the status route never fetches and never runs a git write command. Fetching is a
* separate, user-triggered path (`POST /api/sessions/:id/git-fetch`, src/git-fetch.ts) that the panel calls
* only when the user clicks Fetch. A remote (SSH) or Docker session is not inspected and answers
* `state: 'unsupported'` on both routes, and neither is any repository at or inside a Docker case
* workspace (a container can write there, and git here would run on the host). Ownership goes through
* `findSessionOrFail`, like every session-scoped route.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { redactGitCredentials } from '../../git-clone.js';
import { readDockerCases } from '../../docker-hosts.js';
import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js';
import { readDockerCasesOrThrow } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail } from '../route-helpers.js';
import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js';
import {
emptyOverview,
findWorkspaceRepo,
@@ -28,11 +32,24 @@ import {
type GitRunner,
type GitWorkspaceOverview,
} from '../../git-workspace-status.js';
import type { SessionPort } from '../ports/index.js';
import type { EventPort, SessionPort } from '../ports/index.js';
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* Host paths of every Docker case workspace: repositories at or inside these are never inspected.
* Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list: the Docker rule has
* to be one rule, checked by every path that could run git.
*
* REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it
* in place, so a read can land mid-write). The fetch paths fail closed on that: a fetch runs a repository's
* own hooks, and without the list nothing tells which folders a container can write to. The read-only
* status and diff routes read it as no Docker workspaces, as they always have.
*/
export const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCasesOrThrow(getDataDir())).map((c) => c.hostWorkspacePath).filter(Boolean);
/** The read-only routes' reading of the list: unchanged, an unreadable list is no Docker workspaces. */
const dockerWorkspacesOrNone = (dockerWorkspaces: () => Promise<string[]>): Promise<string[]> =>
dockerWorkspaces().catch(() => []);
/**
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
@@ -50,7 +67,9 @@ function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
export function registerGitStatusRoutes(
app: FastifyInstance,
ctx: SessionPort,
// EventPort as well as SessionPort: the fetch route publishes the run it did, and a route that broadcasts
// has to say so in its own signature.
ctx: SessionPort & EventPort,
git?: GitRunner,
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
): void {
@@ -68,7 +87,7 @@ export function registerGitStatusRoutes(
git,
fresh: fresh === '1',
...limits,
dockerWorkspaces: await dockerWorkspaces(),
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
}),
};
});
@@ -92,7 +111,7 @@ export function registerGitStatusRoutes(
? await findWorkspaceRepo(session.workingDir, repo, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
})
: null;
const status = repoRoot
@@ -119,4 +138,54 @@ export function registerGitStatusRoutes(
);
}
});
// Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one
// `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot
// disagree about which repositories belong to the workspace. This is the only git command the Git panel runs
// that writes, and it writes remote-tracking refs only: nothing commits, checks out or merges. Cloning and
// worktree management write too, but those happen when the user asked for them; this route is the one that
// can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so
// an agent's own `git pull` in the same clone can collide with it, which is why the run goes through
// `fetchWorkspaceOnce`: a click while a tick is already fetching this folder joins that run instead of
// starting a second one. It can also run a
// repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a
// fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn
// maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote
// and Docker sessions answer `unsupported` like the status route.
app.post('/api/sessions/:id/git-fetch', async (req): Promise<ApiResponse<GitFetchReport>> => {
const { id } = req.params as { id: string };
const query = req.query as { maxRepos?: unknown; timeout?: unknown };
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
// Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never
// runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`),
// published like one, so the Session Options Git row shows it.
let dockerRoots: string[];
try {
dockerRoots = await dockerWorkspaces();
} catch (err) {
console.warn(`[git fetch] Docker case list unreadable, nothing fetched: ${getErrorMessage(err)}`);
const report = emptyReport('error', {
error:
'Nothing was fetched: Codeman could not read its Docker case list (docker-cases.json), so it cannot tell which folders a container can write to.',
});
if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session);
return { success: true, data: report };
}
const run = await fetchWorkspaceOnce(
session.workingDir,
{ git, ...limits, dockerWorkspaces: dockerRoots },
'click'
);
// The response answers the click, and the run is also the session's current truth: what the panel's
// "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a
// second tab open on the same folder see the same run. The trigger recorded is the RUN's, not this
// request's: a click that joined a tick already going fetched nothing itself, so the row has to name the
// interval as what fetched.
if (session.setGitFetchStatus(gitFetchStatusFrom(run.report, run.trigger)))
persistAndBroadcastSession(ctx, session);
return { success: true, data: run.report };
});
}
+2
View File
@@ -47,3 +47,5 @@ export {
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
export { registerAgentSessionRoutes } from './agent-session-routes.js';
+6 -14
View File
@@ -5,7 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
@@ -20,10 +20,10 @@ import {
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
SETTINGS_PATH,
findSessionOrFail,
parseBody,
} from '../route-helpers.js';
import { mutateSettingsFile } from '../settings-writer.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -470,18 +470,10 @@ export function registerRalphRoutes(
// in single-user mode.
if (isAdmin(req)) {
try {
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical: the loop starts regardless.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
}
+120 -33
View File
@@ -35,6 +35,7 @@ import {
type RemoteHost,
} from '../../types.js';
import {
GuardedInputBusyError,
Session,
cliTakesSessionModel,
isAltScreenStripMode,
@@ -57,6 +58,7 @@ import {
AutoCompactSchema,
AutoResumeSchema,
KeepWarmSchema,
GitFetchSettingsSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
@@ -114,11 +116,11 @@ import {
resolveCasesDir,
resolveParentSessionId,
sessionCapacityMessage,
SETTINGS_PATH,
validatePathWithinBase,
waitCapacityResponse,
abortOnClientHangUp,
} from '../route-helpers.js';
import { mutateSettingsFile } from '../settings-writer.js';
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
import { agentInbox } from '../agent-inbox.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
@@ -2077,6 +2079,32 @@ export function registerSessionRoutes(
}
}
// A guarded submit (agent-staging.ts) holds the input lease for the few
// milliseconds of its conditional Enter, and input that arrives meanwhile is held
// and written in arrival order after it. A TAGGED request is refused for that span
// (the lease plus the held input's drain) rather than overtake held input: say so
// instead of answering 200 for bytes that were never written, and give the seq
// back so the browser's durable queue (which keeps a frame on any 409, and whose
// POST drainer sends one frame at a time) delivers it on its next retry. An
// UNTAGGED request has no retry behind it (the phone accessory keys and the Stop
// button's Ctrl+C are fire-and-forget POSTs that never read the answer), so it is
// held instead, like a WebSocket frame: `writeViaMux` takes its own turn behind
// the lease, and the raw write below is held explicitly. Nothing from here to the
// write below awaits, so the lease cannot be taken in between.
const refuseGuarded = () =>
reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
'A guarded submit is in progress on this session; nothing was written. Retry the input.'
)
);
if (!duplicate && tagged && session.guardedInputHeld === true) {
session.forgetInputSeq(clientId as string, seq as number);
return refuseGuarded();
}
// Only a waiting request pays for the tmux probe: the browser's plain input path
// (thousands of calls per session) must stay exec-free.
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
@@ -2187,7 +2215,38 @@ export function registerSessionRoutes(
// Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx.
delivered = session.write(inputStr, { fromUser: true });
//
// During a guarded submit (untagged only: a tagged one was refused above) the
// write is held and runs in arrival order once the lease is released. The answer
// is a 200 once held; a send-and-wait reads `delivered` from the held write. Only
// a full hold queue refuses, after releasing the waiter registered above.
let heldWrite: Promise<boolean> | null = null;
if (session.guardedInputHeld === true) {
let settle!: (written: boolean) => void;
const written = new Promise<boolean>((resolve) => {
settle = resolve;
});
const held = session.holdForGuardedLease(
() => {
let ok = false;
try {
ok = session.write(inputStr, { fromUser: true });
} finally {
settle(ok);
}
},
inputStr.length,
inputStr
);
if (held === 'full') {
abort.abort();
stopDeathWatch();
return refuseGuarded();
}
if (held === 'held') heldWrite = written;
}
if (heldWrite && !waitPromise) return {};
delivered = heldWrite ? await heldWrite : session.write(inputStr, { fromUser: true });
if (!delivered && tagged) {
session.forgetInputSeq(clientId as string, seq as number);
}
@@ -2398,7 +2457,7 @@ export function registerSessionRoutes(
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
// 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req) => {
app.post('/api/sessions/:id/send-key', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
@@ -2428,18 +2487,26 @@ export function registerSessionRoutes(
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => {
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
});
await session.performAuxiliaryInput(
() =>
new Promise<void>((resolve, reject) => {
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
})
);
} catch (err) {
// A guarded submit's lease holds the key until its Enter is out; only a full
// hold queue refuses it, and that is a retryable conflict, not a tmux failure.
if (err instanceof GuardedInputBusyError) {
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, err.message));
}
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
}
@@ -3608,6 +3675,40 @@ export function registerSessionRoutes(
};
});
// ========== Git fetch on an interval ==========
// Per-session override for the fetch interval (src/git-fetch-scheduler.ts), which is what the Session
// Options rows say it is: two sessions on one folder can hold different choices, and that folder is
// fetched when the first of them is due. `null` on either field clears this session's choice so the
// app-wide setting applies again, and a field left out of the body leaves the other half as it was: the
// Session Options rows save on their own. Refused for a remote or Docker session, exactly as the Git
// panel excludes them: a container can write to the repository it mounts, so git there would run that
// repository's own configuration on the host.
app.post('/api/sessions/:id/git-fetch-settings', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions');
}
const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled;
const intervalSeconds =
'intervalSeconds' in body ? (body.intervalSeconds ?? undefined) : session.gitFetchIntervalSeconds;
session.setGitFetchSettings(enabled, intervalSeconds);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
gitFetchSettings: {
enabled: session.gitFetchEnabled ?? null,
intervalSeconds: session.gitFetchIntervalSeconds ?? null,
},
},
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
@@ -4576,28 +4677,14 @@ export function registerSessionRoutes(
// settings.json, readable by every user through GET /api/settings, so a non-admin's
// case name is not written there in multi-user mode (writes are admin-only, like
// PUT /api/settings). isAdmin is always true in single-user mode.
// Awaited and serialized with every other settings.json writer (mutateSettingsFile): the
// un-awaited write this replaced could land after the response, over a settings save.
if (isAdmin(req)) {
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
// Non-critical: the session is already running.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
}
+92 -62
View File
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { readHostSensors } from '../../host-sensors.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isExplicitlyEnabled, isUnauthenticatedTunnelAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
@@ -48,6 +48,7 @@ import {
toggleService,
SETTINGS_PATH,
} from '../route-helpers.js';
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
@@ -177,6 +178,43 @@ export function settingsForNonAdmin(settings: unknown): unknown {
return out;
}
/**
* True when a login protects the server: CODEMAN_PASSWORD is set, or multi-user mode is
* on (every person has their own credential). The tunnel guard and the `loginConfigured`
* field of GET /api/tunnel/status both read this, so the page's answer mirrors the guard.
*/
function isLoginConfigured(): boolean {
return !!process.env.CODEMAN_PASSWORD || isMultiUserMode();
}
/**
* The refusal of a tunnel enable with no login and no tunnel opt-in or acknowledgement: logs
* the diagnostic line and returns the message for the 403. `late` is the case where the tunnel
* was running when the request arrived and had stopped by the time it would be started; the
* request's other settings were saved by then.
*/
function unauthenticatedTunnelRefusal(late: boolean): string {
// Someone who set the network variable to get here before #706 is told why it stopped working.
const networkAcknowledged = isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
console.warn(
`[tunnel] Refused an unauthenticated tunnel ${late ? 'restart after a concurrent stop' : 'enable'} (no login, no tunnel opt-in, no acknowledgement${networkAcknowledged ? '; CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK does not cover the tunnel' : ''})`
);
return (
(late
? 'The Cloudflare tunnel stopped while these settings were being saved and was not restarted; ' +
'the other settings were saved. '
: '') +
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.' +
(networkAcknowledged
? ' CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK is set, but it only acknowledges the network ' +
'bind and no longer covers the tunnel.'
: '')
);
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
@@ -194,7 +232,14 @@ export function registerSystemRoutes(
// ========== Tunnel ==========
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
// `loginConfigured` (#706) tells the page whether a tunnel would have a login in front
// of it, so it can ask before an enable even when the server would accept one. It is
// the guard's own login test (isLoginConfigured above), never TunnelManager's, which
// knows nothing about auth.
app.get('/api/tunnel/status', async () => ({
...ctx.tunnelManager.getStatus(),
loginConfigured: isLoginConfigured(),
}));
app.get('/api/tunnel/info', async (req) => {
const status = ctx.tunnelManager.getStatus();
@@ -1032,11 +1077,10 @@ export function registerSystemRoutes(
app.get('/api/settings', async (req) => {
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
// and every page load calls this route, so a "persist the default when the
// key is absent" reconcile here replaced a whole settings file with one key
// on the first unlucky read. The plan-usage default is resolved by the
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not
// only for a missing file, and every page load calls this route, so a
// "persist the default when the key is absent" reconcile here replaced a
// whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the
// READERS instead: an absent `showPlanUsageLimits` means ON to
// readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent
// `workspaceHooksEnabled` means ON, and the client resolves its own display
@@ -1063,54 +1107,41 @@ export function registerSystemRoutes(
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in, or an
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
// confirm dialog). This keeps curl/API/CLI callers protected by default while
// letting an operator opt in from the browser without setting the env var.
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
// Multi-user mode makes the tunnel authenticated (every person has their own
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
const acknowledged =
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
// Loud warning whenever a public tunnel is started with no password — whether
// acknowledged via env var or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
// The tunnel opt-in is CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL / --allow-unauthenticated-tunnel,
// never the network one (#706): install.sh writes CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK
// for every passwordless LAN install, and that acknowledges the LAN bind only.
// Decided ONCE per request, whether or not the tunnel is running now, and required again
// where the tunnel is really started below: the settings file awaits in between leave room
// for a concurrent stop, and a save that found the tunnel running (so nothing was refused
// here) must not then restart it unguarded.
const tunnelEnableAuthorized =
settings.tunnelEnabled === true &&
(isLoginConfigured() ||
isUnauthenticatedTunnelAcknowledged(ctx.allowUnauthenticatedTunnel) ||
settings.acknowledgeUnauthTunnel === true);
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized && !ctx.tunnelManager.isRunning()) {
const msg = unauthenticatedTunnelRefusal(false);
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
// it before persisting so settings.json stays clean.
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
// An enable that is not authorized only got this far because the tunnel was running.
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
@@ -1182,6 +1213,20 @@ export function registerSystemRoutes(
if ('tunnelEnabled' in settings) {
const tunnelEnabled = settings.tunnelEnabled as boolean;
if (tunnelEnabled && !ctx.tunnelManager.isRunning()) {
// The tunnel stopped since the guard above saw it running: the same authorization applies.
if (!tunnelEnableAuthorized) {
reply.code(403);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true));
}
// Loud warning whenever a public tunnel is started with no login in front of it, whether
// acknowledged via the tunnel opt-in or the per-request UI confirmation.
if (!isLoginConfigured()) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
ctx.tunnelManager.start(ctx.port, ctx.https);
console.log('Tunnel started via settings change');
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
@@ -1196,7 +1241,7 @@ export function registerSystemRoutes(
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
@@ -1214,25 +1259,10 @@ export function registerSystemRoutes(
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
let existingSettings: Record<string, unknown> = {};
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
existingSettings = JSON.parse(content);
} catch {
// File doesn't exist yet, start fresh
}
existingSettings.modelConfig = modelConfig;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
await mutateSettingsFile((current) => ({ ...current, modelConfig }));
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
+83 -45
View File
@@ -29,6 +29,11 @@
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
* optional reliable-delivery tags: the server applies each
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
* A frame that arrives while a guarded submit holds the
* session's input lease is held, then applied and ACKed in
* arrival order once it is released; when that hold queue
* is full the socket is closed with 1013 and nothing later
* from it is applied, so the client resends in order.
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
*/
@@ -169,12 +174,69 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const sizingToken = Symbol('ws-desktop-sizing');
let holdsDesktopClaim = false;
// Set once an input frame could not be held behind a guarded submit's lease
// (the hold queue was full). Nothing later from this socket is applied: it is
// closed, and the browser reconnects and resends everything unACKed, in order.
let inputStalled = false;
// Apply one input frame and ACK it. Runs inline, or later and in arrival order
// when a guarded submit held the input lease as the frame arrived.
const applyInputFrame = (msg: { d: string; seq?: unknown; cid?: unknown }) => {
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
let delivered = true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
// Browser keystrokes are the user's own, so they may name the tab.
delivered = session.write(msg.d, { fromUser: true });
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
// a duplicate: the input was lost for good.
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
}
if (seq !== null && socket.readyState === 1) {
if (apply) {
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
} else {
// REJECTED as a duplicate. ACK it — the client must still drop it
// from its durable queue — but say so, and hand back our watermark.
//
// A plain ACK here is indistinguishable from "applied", which is
// what made a client with a rolled-back counter unrecoverable: its
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
// between a send and that write comes back with a counter BELOW this
// watermark, every later keystroke lands at or under it, and each one
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
// a reload restores the same stale counter. `last` is what lets the
// client lift itself out.
// ⚠️ Defensive: the session arrives through a structural port, and an
// implementation without this method must not take the whole input
// path down with it — a throw here aborts the message handler and the
// frame is never ACKed at all, which strands it in the client's queue.
const watermark =
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
: seq;
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
}
}
};
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
socket.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (inputStalled) return;
if (msg.d.length > MAX_INPUT_LENGTH) {
// Refused for good, so say so: a silent return left the frame
// unACKed and the client redelivered it every few seconds forever
@@ -185,51 +247,27 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
}
return;
}
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
let delivered = true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
// Browser keystrokes are the user's own, so they may name the tab.
delivered = session.write(msg.d, { fromUser: true });
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
// a duplicate: the input was lost for good.
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
}
if (seq !== null && socket.readyState === 1) {
if (apply) {
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
} else {
// REJECTED as a duplicate. ACK it — the client must still drop it
// from its durable queue — but say so, and hand back our watermark.
//
// A plain ACK here is indistinguishable from "applied", which is
// what made a client with a rolled-back counter unrecoverable: its
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
// between a send and that write comes back with a counter BELOW this
// watermark, every later keystroke lands at or under it, and each one
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
// a reload restores the same stale counter. `last` is what lets the
// client lift itself out.
// ⚠️ Defensive: the session arrives through a structural port, and an
// implementation without this method must not take the whole input
// path down with it — a throw here aborts the message handler and the
// frame is never ACKed at all, which strands it in the client's queue.
const watermark =
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
: seq;
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
}
// A guarded submit (agent-staging.ts) holds the input lease across its
// awaited Enter, so frames keep arriving while it is held. Refusing one
// lost it: this path does not wait for ACKs, so the next seq landed after
// the lease, moved the watermark past the refused one, and its redelivery
// was then dropped as a duplicate. Hold the frame instead; the session runs
// it, ACK included, in arrival order the moment the lease is released.
// ⚠️ Defensive, like `lastInputSeq` below: the session arrives through a
// structural port, and one without this method must still deliver input
// (a throw here would drop the frame unACKed).
const held =
typeof session.holdForGuardedLease === 'function'
? session.holdForGuardedLease(() => applyInputFrame(msg), msg.d.length, msg.d)
: 'free';
if (held === 'free') {
applyInputFrame(msg);
} else if (held === 'full') {
// Not held, not applied, not ACKed, and no later frame from this socket
// is applied either, so nothing can overtake it.
inputStalled = true;
console.warn('[ws] input backlog during a guarded submit; closing for redelivery', { sessionId: id });
socket.close(1013, 'Input backlog during guarded submit');
}
} else if (
msg.t === 'z' &&
+42 -1
View File
@@ -1488,6 +1488,19 @@ export const SettingsUpdateSchema = z
promptCacheKeepWarm: z.boolean().optional(),
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel
* lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the
* server is what runs it: `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 the moment they asked), and
* 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`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A
* session can override both in Session Options → Git; `null` there clears the override, and sessions
* sharing one folder are fetched by one run.
*/
gitFetchEnabled: z.boolean().optional(),
gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(),
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
@@ -1544,6 +1557,13 @@ export const SettingsUpdateSchema = z
* inboxes stay). On, pending messages between agents survive a server restart.
*/
agentInboxPersistEnabled: z.boolean().optional(),
/**
* Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded
* inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`.
* SYNCED, default OFF: the routes are labeled experimental and may change in a minor or
* patch release, so automation has to opt in. While OFF, all three routes answer 403.
*/
agentCoordinationEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1554,7 +1574,7 @@ export const SettingsUpdateSchema = z
// Action field (NOT persisted): explicit per-request acknowledgment that the
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
// Lets the UI enable a tunnel after a confirm dialog without the
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
// CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
/**
@@ -1928,6 +1948,15 @@ export const AutoResumeSchema = z.object({
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
/**
* `POST /api/sessions/:id/git-fetch-settings`: `null` clears the session's choice so the app-wide default
* applies. Both fields are optional, so a row that saves on its own does not clear the other.
*/
export const GitFetchSettingsSchema = z.object({
enabled: z.boolean().nullable().optional(),
intervalSeconds: z.number().int().min(30).max(3600).nullable().optional(),
});
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
@@ -2410,3 +2439,15 @@ export const CustomModelSelectionSchema = z.union([
}),
z.object({ clear: z.literal(true) }),
]);
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
export const AgentInspectQuerySchema = z
.object({
maxBytes: z.coerce.number().int().min(1).max(65536).default(8192),
since: z.string().uuid().optional(),
})
.strict();
export const AgentStageSchema = z.object({ message: z.string().min(1).max(8192) }).strict();
export const AgentSubmitSchema = z
.object({ stageId: z.string().uuid(), confirmSha: z.string().regex(/^[a-f0-9]{64}$/) })
.strict();
+101 -11
View File
@@ -100,6 +100,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readGitFetchSettings,
readKeepWarmSettingsOrThrow,
readPlanUsageTelemetryEnabled,
removeAgentSessionPreamble,
@@ -123,6 +124,10 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { fetchWorkspaceOnce } from '../git-fetch.js';
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
import { probePathKind } from '../utils/bounded-path-probe.js';
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
@@ -174,6 +179,7 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { mutateSettingsFile } from './settings-writer.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
@@ -213,6 +219,7 @@ import {
registerCaseRoutes,
registerWorktreeRoutes,
registerSessionRoutes,
registerAgentSessionRoutes,
registerRespawnRoutes,
registerRalphRoutes,
registerPlanRoutes,
@@ -293,6 +300,7 @@ import {
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
CRON_TICK_INTERVAL,
GIT_FETCH_TICK_INTERVAL_MS,
} from '../config/server-timing.js';
/**
@@ -389,6 +397,37 @@ export class WebServer extends EventEmitter {
},
log: (message) => console.log(message),
});
// Git fetch on an interval (git-fetch-scheduler.ts): the due-tracking lives in the service; the server
// lends it the facts a decision needs and stores what it reports on the session. A field initializer for
// the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick:
// that is what makes a live toggle take effect on the next tick, and what makes turning it off actually
// stop the fetching. Default OFF, so an install that never enabled it spawns nothing at all.
private readonly gitFetchScheduler = new GitFetchScheduler({
sessionIds: () => this.sessions.keys(),
getSession: (id) => this.sessions.get(id),
readSettings: readGitFetchSettings,
// The same Docker list the panel's Fetch button uses: the rule has to be one rule. It rejects on a list
// that cannot be read, and the tick then fetches nothing (fail closed).
dockerWorkspaces: defaultDockerWorkspaces,
// Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must
// never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched.
probePath: (path) => probePathKind(path),
// Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the
// tick is already fetching joins it rather than starting a second `git fetch` in the same clone, and the
// run that gets published is the one that ran, with its own trigger.
fetch: (cwd, opts, trigger) => fetchWorkspaceOnce(cwd, opts, trigger),
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setGitFetchStatus(status)) return;
// No new SSE event: the status rides the session state the page already listens for, and the Git
// panel polls on its own clock. It is 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 (src/types/session.ts), so
// what a run found is the panel's truth for the life of the session, not across a restart.
this.persistSessionState(session);
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
},
log: (message) => console.log(message),
});
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
@@ -472,6 +511,8 @@ export class WebServer extends EventEmitter {
private windowTitle: string;
private readonly indexHtmlTemplate: string;
private readonly allowUnauthenticatedNetwork: boolean;
/** `--allow-unauthenticated-tunnel`; the tunnel guard reads it through the route context (#706). */
private readonly allowUnauthenticatedTunnel: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
@@ -489,7 +530,8 @@ export class WebServer extends EventEmitter {
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
basePath: string = '',
allowUnauthenticatedTunnel: boolean = false
) {
super();
this.setMaxListeners(0);
@@ -501,6 +543,7 @@ export class WebServer extends EventEmitter {
this.testMode = testMode;
this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
this.allowUnauthenticatedTunnel = allowUnauthenticatedTunnel;
this.titleHostname = titleHostname || getHostname();
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
@@ -880,6 +923,7 @@ export class WebServer extends EventEmitter {
scheduledRuns: this.scheduledRuns,
teamWatcher: this.teamWatcher,
tunnelManager: this.tunnelManager,
allowUnauthenticatedTunnel: this.allowUnauthenticatedTunnel,
pushStore: this.pushStore,
startScheduledRun: this.startScheduledRun.bind(this),
stopScheduledRun: this.stopScheduledRun.bind(this),
@@ -1242,6 +1286,21 @@ export class WebServer extends EventEmitter {
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
// Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in
// tests for the same reason as the polls below: a test drives `tick()` directly instead of waiting on
// a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning
// it on takes effect on the next tick and turning it off actually stops the fetching.
if (!this.testMode) {
this.cleanup.setInterval(
() => {
this.gitFetchScheduler.tick().catch((err) => {
console.error('[git fetch] interval tick failed:', getErrorMessage(err));
});
},
GIT_FETCH_TICK_INTERVAL_MS,
{ description: 'git fetch interval checker' }
);
}
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1249,6 +1308,7 @@ export class WebServer extends EventEmitter {
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
registerAgentSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);
@@ -1499,6 +1559,9 @@ export class WebServer extends EventEmitter {
): Promise<void> {
const session = this.sessions.get(sessionId);
this.keepWarmService.remove(sessionId);
// A closed session takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id
// would still count as tracked, and its next run would land on a session that no longer exists.
this.gitFetchScheduler.remove(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
@@ -1914,6 +1977,16 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
);
// Which run modes keep their PTY rows under a phone keyboard (inline renderers,
// the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same
// shape and reasoning as the page-key list above.
const keyboardHoldClis = enabledClis()
.filter((entry) => entry.capabilities.keyboardHold === 'inline')
.map((entry) => entry.id);
html = html.replace(
'</head>',
() => `<script>window.__codemanKeyboardHoldModes=${JSON.stringify(keyboardHoldClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
@@ -3346,16 +3419,12 @@ export class WebServer extends EventEmitter {
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
if (await this.isTunnelEnabled()) {
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
settings.tunnelEnabled = false;
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
} catch {
/* ignore */
await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false }));
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
} catch (err) {
console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err));
}
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
}
// Start team watcher for agent team awareness (always on — lightweight polling)
@@ -3540,6 +3609,9 @@ export class WebServer extends EventEmitter {
if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) {
session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold);
}
if (saved.gitFetchEnabled !== undefined || saved.gitFetchIntervalSeconds !== undefined) {
session.setGitFetchSettings(saved.gitFetchEnabled, saved.gitFetchIntervalSeconds);
}
if (saved.autoResumeEnabled) {
// The stamp is re-armed by default, because a Codeman restart leaves the
// limit footer un-reprinted and dropping it there would strand the pause.
@@ -3762,6 +3834,11 @@ export class WebServer extends EventEmitter {
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
// The session's own fetch switch and interval, carried over rather than reset to the app default:
// the choice is the user's and outlives the run that made it. The scheduler reads them fresh
// on its next tick, so a restored session is back on its own schedule without a restart.
gitFetchEnabled: savedState?.gitFetchEnabled,
gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
@@ -4233,6 +4310,9 @@ export class WebServer extends EventEmitter {
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.stop();
// Stops the fetch sweep from starting another run while the server is closing; a git process already
// running is left to finish, which is what a fetch does to a repository nothing else is touching.
this.gitFetchScheduler.stop();
// A post within the debounce window before shutdown would otherwise not reach disk.
// Flushed again after `app.close()` below, for a post that lands in between.
agentInboxPersistence.flush();
@@ -4399,9 +4479,19 @@ export async function startWebServer(
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
basePath: string = '',
allowUnauthenticatedTunnel: boolean = false
): Promise<WebServer> {
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
const server = new WebServer(
port,
https,
testMode,
host,
titleHostname,
allowUnauthenticatedNetwork,
basePath,
allowUnauthenticatedTunnel
);
await server.start();
return server;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`).
*
* Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as
* `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is
* config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that
* does not parse, which this writer refuses.
*
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading,
* changing and writing before the next starts. The writers used to run unserialized with a
* plain truncate-then-write `fs.writeFile`, and quick-start did not even await its
* `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one
* write's bytes over the tail of a longer one) or drop the other writer's key.
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it,
* so a reader never sees a half-written file.
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file
* that cannot be read or does not parse is left alone and the write throws
* `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only
* their own keys, which erased every other setting.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js';
import { SETTINGS_PATH } from './route-helpers.js';
export type SettingsFile = Record<string, unknown>;
/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */
export class SettingsWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SettingsWriteRefusedError';
}
}
async function readSettingsForWrite(path: string): Promise<SettingsFile> {
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new SettingsWriteRefusedError(
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
);
}
// An empty file holds no settings to lose.
if (raw.trim() === '') return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
// The parser's own message is not repeated: it can quote the file, which may hold an API key.
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new SettingsWriteRefusedError(
`${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`
);
}
return parsed as SettingsFile;
}
async function writeSettingsFile(path: string, settings: SettingsFile): Promise<void> {
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
const target = await fs.realpath(path).catch(() => path);
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Run one change to `settings.json`. `fn` receives the current settings and returns the settings
* to write, or `undefined` to write nothing. Resolves with the settings before and after the
* change (`after` is `undefined` when nothing was written). A refused or failed mutation rejects
* its own caller only; the chain keeps going.
*/
export function mutateSettingsFile(
fn: (current: SettingsFile) => SettingsFile | undefined
): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> {
const run = mutationChain.then(async () => {
const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => {
console.warn(`[settings] write refused: ${getErrorMessage(err)}`);
throw err;
});
const after = fn({ ...before });
if (after) await writeSettingsFile(SETTINGS_PATH, after);
return { before, after };
});
mutationChain = run.catch(() => {});
return run;
}
/**
* Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right
* now: the writer's own read-for-write rule, run on its own. For a route that does something it
* cannot take back (case rename ends sessions) before its settings write. A check only, outside
* the chain (writes are atomic, so it never reads a half-written file); the file can still change
* before the write.
*/
export async function assertSettingsWritable(): Promise<void> {
await readSettingsForWrite(SETTINGS_PATH);
}
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */
export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> {
if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more