Compare commits

...
74 Commits
Author SHA1 Message Date
Codeman maintainer 3e06f3c141 chore: version packages
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 17:19:51 +02:00
Codeman maintainer 48ad9fc7ca fix: document the case-rename route as open to non-admins, with only its settings.json part admin-only (release review)
docs/api-reference.md listed POST /api/cases/:name/rename in the Settings
table, whose introduction says every route in it answers 403 FORBIDDEN to a
non-admin in multi-user mode, and its paragraph said caseOrder and
lastUsedCase are always updated. Neither is what the route does (and what
CLAUDE.md, the wiki and test/routes/case-rename-routes.test.ts say): a
non-admin renames their own case (200), and only the server-wide
settings.json write is skipped (`if (isAdmin(req))`).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Update the docs that still described 10 per IP for every mode, and resync
the plugin mirror of the codeman skill.
2026-10-22 21:21:49 -04:00
Aamer Akhter c6a5995ac6 feat(palette): choose the agent when starting a session from Cmd+K
The palette's "New session" row now shows the agent it will start and
lets Left/Right (or the row's arrows) switch it. The agents come from the
injected CLI catalog, filtered to enabled and installed entries, so no CLI
id list lives in the palette. The pick is a one-shot launch through run()
with a temporary _runMode swap: the Run button's mode is left unchanged.
2026-10-22 17:23:03 -04:00
Aamer Akhter 4f9274af3a fix(auth): require the request on findSessionOrFail
findSessionOrFail took `req` as optional, so the quiet default was the
unscoped lookup: a route that forgot to pass it compiled cleanly and, in
multi-user mode, returned another user's session. `req` is now required,
and a caller with no request in flight must use the explicit, greppable
findSessionUnscopedOrFail instead.

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.44.0",
"version": "1.45.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+37
View File
@@ -1,5 +1,42 @@
# aicodeman
## 1.45.0
### Minor Changes
- ### Thanks
- @opticon454 for five PRs: renaming a case and its folder with the live sessions handled (#680), a resizable line between the tabs and the header toolbar (#689), opt-in GPU and CPU clock readings in the header (#692), and two Codex wheel fixes measured against the real CLI, a first notch that moves the transcript (#699, from #697) and a wheel that no longer moves a selection menu's choice (#698, from #696).
- @aakhter for five PRs: an agent picker on the Cmd+K New session row (#685), a login throttle that no longer locks a whole tunnel out after ten typos (#684), an SSE connect attempt that can no longer hang forever (#678), prompts that start with a hyphen (#677), and `req` made required on the session lookup helpers so an ownership check cannot be skipped by accident (#683).
- @JDProfresh for a phone swipe that follows the order the strip draws (#682) and an inline Claude tab whose history loads instead of paging the composer after a server restart (#681), both found and tested on an iPhone.
- @Randalix for the per-session agent inbox (#690), the shape agreed in Discussion #558.
- @Hugo-DDT for closing the last two ways a key binding could move a client onto another session (#688), and @christianhaberl for reporting both in #671.
- @johoja12 for remote hosts whose tmux lives in Homebrew or a per-user bin directory (#691), the follow-up promised in #645.
**Rename a case and its folder (#680).** The case manager has a Rename action. A case under `~/codeman-cases` is renamed as a name and folder; a linked case can rename only its name or its real folder too. Live sessions that have the folder open are listed and only stopped if you agree, the folder is locked while it moves so nothing starts in it half-moved, a folder another program holds open is reported instead of forced, and Claude Code's conversation history (including the Resume list), the case order, the last used case and the default session group follow the new name.
**A per-session agent inbox (#690).** Agents can leave each other messages without typing into anyone's pane: `codeman agent post <id> "<text>"`, `codeman agent inbox [--wait]` and `codeman agent ack <ids>`, over `POST`/`GET /api/sessions/:id/inbox`, `POST .../inbox/ack` and `GET /api/agent-inbox/summary`. A message stays until its receiver acknowledges it, so nothing is lost between a read and the ack, and `codeman agent ls` shows an INBOX column. Inboxes live in memory by default; **Keep agent inbox across restarts** (Settings, Cross-session features, off by default) writes them to a 0600 file in the data dir, kept for 12 hours.
**Pick the agent from Cmd+K (#685).** The palette's New session row shows the agent it will start, with arrows to change it (Left/Right while the row is highlighted). It starts on the Run button's mode, offers the same agents the Run menu offers for that case (a Docker case's container agents included), and launches once without changing the Run button.
**A resizable header toolbar (#689).** Drag the line between the session tabs and the header toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then (in the Compact style) their percentages as it narrows; double-click or Home resets it, the arrow keys move it, and the width is remembered on the device.
**GPU and CPU clock readings (#692, opt-in).** **GPU Stats** adds GPU load, graphics memory and the GPU clock after CPU and MEM (each only when the host reports it), and **CPU MHz** adds the live CPU clock. Both are in Settings, Header & Panels, off by default, and nothing is read unless the browser asks. NVIDIA is read through `nvidia-smi`, AMD through sysfs, and inside WSL any vendor through the Windows performance counters (no GPU clock there). The header also keeps polling its stats with no session open, so CPU and MEM no longer sit at `--`.
**Fixes.**
- **A tunnel no longer locks everyone out after ten typos (#684, multi-user mode).** Behind `tailscale serve` or a tunnel every login arrives from one address, so ten wrong passwords from anyone used to lock every user out for 15 minutes. The per-address limit is now 50, and each account allows ten failed guesses per address, both checked before the password is verified. Another address can no longer lock an account out, a burst of parallel guesses counts in full, and identical logins in flight share one check, so a reload with many tabs open is never refused. Single-user mode is unchanged.
- **The first wheel notch moves a Codex transcript (#699).** When a CLI does not track the mouse, Codeman turns the wheel into PageUp/PageDown, and it used to send nothing until half a screen of travel had built up: 5 notches in Chrome, 7 in Firefox. The first notch now pages at once and the notches after it repay it, so the page rate is unchanged.
- **The wheel no longer moves a Codex menu's selection (#698).** At "Implement this plan?" one notch down used to land on "No, stay in Plan mode" and one up on "Yes, implement this plan", because those menus use the page keys. The wheel is now ignored while such a menu shows, in the main pane and in tiles, and pages the transcript again once it closes.
- **History loads after a restart instead of paging Claude (#681).** After a server restart, a second visit to an inline Claude tab on a phone turned every swipe into PageUp/PageDown for Claude, so the composer jumped around and old output never showed. The swipe now loads tmux's history, as the first visit does.
- **A phone swipe follows the strip (#682).** With the case or state layout, a sorted rail or named groups, swiping now moves to the session drawn next to the current one, not the next one in the hidden canonical order. Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, matching the Alt+N badges.
- **Key bindings can no longer move a client onto another session (#688, fixes #671).** The tmux servers behind Docker cases and owned remote sessions keep only their send-prefix binding, as Codeman's own socket has since 1.44.0, and `codeman tui`'s Alt+1..9 switch only the TUI's own client: an Esc then a digit in a browser tab reaches its pane instead of switching it.
- **Remote tmux found in Homebrew or a per-user bin (#691).** Every remote tmux call (availability check, launch, attach, discovery, capture, cleanup) now runs on one widened, quoted PATH, so a host whose tmux works only in an interactive shell links and launches, home directories with spaces included.
- **Prompts that start with a hyphen (#677).** Text typed into a pane that began with `-` (`-x hello`, `--help`) was read by tmux as its own options and never arrived.
- **A connect attempt that never opens is retried (#678).** After a phone resumes on a VPN or tailnet, an event stream could sit in "connecting" forever with no error to trigger a retry. Each attempt now gets 15 seconds before it is closed and retried with the usual backoff.
**Fixes applied while landing.** A case rename that fails half way now puts `settings.json` back too and says where the folder is; it merges Claude Code's history into a folder a deleted case of the new name left behind instead of silently leaving it at the old name, refuses a dangling symlink at the new name before any session is stopped, refuses to move a folder while a session is still being created in it, and the dialog says plainly that stopping closes the sessions (#680). The inbox keeps the mail of sessions on the reboot-restore offer and of a detached session that comes back, prints message text with control characters escaped, and flushes after the server stops accepting posts (#690). Cmd+K's agent list follows the case's own availability rule, so a Docker case can start a CLI only its container has, and with a single choice the row keeps the Run button's mode (#685). The divider's stored width can no longer go negative and Escape no longer resets it (#689). With the toolbar narrowed, the GPU, VRAM and clock readings keep their labels, so they can still be told apart from CPU and MEM, and at Compact's rings-only step a clock with nothing left to show is hidden instead of leaving a gap (#689, #692). The stats route no longer waits on Windows' RAM probe, and a hung `nvidia-smi` can no longer freeze the header's CPU and MEM readings: the `nvidia-smi` and Windows RAM probes now give up after 1.5 s (#692). A second `codeman tui` on a longer pts number can no longer remove the first one's guard, and a client that fails the guard gets its key instead of losing it (#688). A test that had started passing vacuously after the SSE change checks the connect URL again (#678).
**Docs and tests.** `req` is now required on `findSessionOrFail` and `findPersistedSessionOrFail`, pinned by a test that also checks its exact type (#683). New sections for the agent inbox, the case rename, the header divider and the host sensors, the SSE connect timer next to the staleness watchdog, the login buckets in the security docs, and the remote PATH rule in the architecture notes.
## 1.44.0
### Minor Changes
+10 -6
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.44.0 (must match `package.json`)
**Version**: 1.45.0 (must match `package.json`)
## Project Overview
@@ -102,7 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so while a `codeman tui` attach is open its `root` keys reach browser tabs too, Alt+1..9 `switch-client` included (open follow-up). ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
@@ -181,6 +181,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
@@ -193,7 +195,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order (Alt+[ / Alt+] and Ctrl+Tab included), except the phone swipe, which walks the drawn order like the strip's arrow keys (`nextSession({ visual: true })` through `_stripSessionOrder()`), and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
@@ -203,6 +205,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
@@ -211,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
@@ -274,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **CSRF / Origin** | Always-on cross-site Origin guard on state-changing requests. **A missing Origin is allowed** so curl/CLI and hooks keep working. ⚠️ The body parser keeps `text/plain` RAW; auto-JSON-parsing it enabled simple-request CSRF |
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead checks 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) BEFORE the password, so past either even a correct password from that IP waits (the account still logs in from another IP), plus 10 per username AFTER it (429 to wrong guesses from any IP, never to a correct password). QR and hook-secret have separate buckets, so neither can lock out login |
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
@@ -307,7 +311,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
+6 -2
View File
@@ -580,7 +580,7 @@ loginctl enable-linger $USER
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`)
2. On success → server issues a `codeman_session` cookie (24h TTL, auto-extends on activity)
3. Subsequent requests authenticate silently via cookie
4. 10 failed attempts per IP → 429 rate limit (15-minute decay)
4. 10 failed attempts per IP → 429 rate limit (15-minute decay). In multi-user mode the per-IP cap is 50, and each account allows 10 failed guesses per IP; past either, even a correct password from that IP waits for the decay, while the account still logs in from another IP
**Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions.
@@ -670,7 +670,7 @@ By default Codeman launches sessions with `--dangerously-skip-permissions`, so t
- **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. Multi-user mode allows 50 failures per IP instead, plus 10 failed guesses per account from one IP; both are checked before the password, so past either a correct password from that IP also waits for the decay, while the account still logs in from another IP. QR auth has its own separate limiter
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Agents & CLIs → Claude → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
### Always-on browser hardening (v0.9.5)
@@ -988,6 +988,9 @@ codeman agent read "$SID" --tail 3000 # terminal tail, ANSI
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane
codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new
codeman agent ack <msg-id> # remove what you handled (read never removes)
codeman agent rm "$SID" # any session except this one
```
@@ -1067,6 +1070,7 @@ REST over Fastify — **~230 handlers across 25 route modules**, plus an SSE str
| `GET` | `/api/sessions/:id/last-response` | The last answer as clean text, read from the transcript (claude, codex, deepseek) |
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
| `POST` / `GET` | `/api/sessions/:id/inbox` | Agent inbox: post `{text}` / read every pending message (`?wait=` blocks while nothing is new); `POST …/inbox/ack {ids}` removes them, receiver only |
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
+2 -2
View File
@@ -581,7 +581,7 @@ loginctl enable-linger $USER
1. 首次请求 → 浏览器弹出 Basic Auth 提示(用户名:`admin` 或 `CODEMAN_USERNAME`)
2. 成功后 → 服务端签发 `codeman_session` cookie(24 小时 TTL,活动时自动延长)
3. 后续请求通过 cookie 静默认证
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)。多用户模式下每个 IP 的上限为 50 次,且同一 IP 对每个账户最多 10 次失败;超过任一上限后,来自该 IP 的正确密码也须等待衰减,但该账户仍可从其他 IP 登录
通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。
@@ -671,7 +671,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
- **默认仅环回** —— 服务器程序本身绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全(引导式安装器会询问网络访问方式,并替你配置绑定和密码)。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。多用户模式下每个 IP 改为 50 次,同一 IP 对每个账户另有 10 次失败上限;两者都在校验密码之前检查,超过任一上限后,来自该 IP 的正确密码也须等待衰减,但该账户仍可从其他 IP 登录。二维码认证有自己独立的限制器
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
### 始终开启的浏览器加固(v0.9.5)
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.44.0",
"version": "1.45.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.44.0",
"version": "1.45.0",
"transport": {
"type": "stdio"
},
+2
View File
@@ -26,6 +26,8 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-search.browser.test.ts',
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/header-divider.browser.test.ts',
'test/header-host-sensors.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
'test/opencode-resize.test.ts',
+95 -1
View File
@@ -591,10 +591,102 @@ request returns. The capture runs synchronous tmux calls on the server; the
## The `codeman agent` CLI (client over these endpoints)
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman agent ls|spawn|send|wait|read|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 mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
## Agent inbox
A per-session mailbox: another agent (or the user) posts a message, and the
receiving agent reads it when it chooses to. Nothing is typed into the
receiver's pane, which is the difference from `POST …/input`: a post does not
start or interrupt a turn, and the text may span several lines. Design and the
reasons behind each rule: [discussion #558](https://github.com/Ark0N/Codeman/discussions/558).
| Method | Route | Body / query | Answer (`data`) |
|---|---|---|---|
| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` |
| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=<ms>` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` |
| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` |
| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` |
A message is `{ id, from, text, createdAt, seen }`. `from` is a session id or
`null` (the user); `seen` says whether the receiver had read it before this read.
**Who is calling.** The server resolves the caller from the
`X-Codeman-Parent-Session` header, the one `codeman agent` sends on every
request, with the same resolver as session lineage: a live session the caller
can see, with the same owner as the receiving session, matched by exact id or a
unique prefix of at least 8 characters. Anything else counts as no session.
The body cannot name a sender (`InboxPostSchema` is strict). In multi-user mode
that means a caller cannot claim to be another owner's session. Admins are the
exception, as everywhere else: an admin can see every session, so an admin's
request can act as any session of the receiver's owner. In single-user
mode every local process holds the same credentials, so the header is a hint the
client gives about itself, not proof.
**Read until ack.** A read returns every pending message, oldest first, and
removes nothing. Only `POST …/ack` removes, by id. Acking an id that is already
gone is a success, so a retry after a lost response does not fail.
**Seen is the receiver's.** A read marks messages seen only when the resolved
caller is the receiving session itself (`receiver: true`). Any other read, a
coordinator checking a worker or a request without the header, is a peek and
changes nothing. `ack` from anyone but the receiver is `403 FORBIDDEN`.
**`?wait=`** blocks while the inbox holds nothing **unseen**. A message that
was read but not acked does not end the wait, so a receiver that keeps one
pending does not spin. The value is clamped to the agent-wait bounds like
`timeout` on `/wait`, a timeout is a `200` with `wait.timedOut: true`, and the
waiter counts against the same per-session, per-owner and total caps (`409
SESSION_BUSY` / `429 RATE_LIMITED`). `wait` has the shape of the other wait
results plus `posted` (a post released it). A session deleted during the wait
answers `wait.ended: true` with an empty inbox. A detach or a server shutdown
ends the wait with `ended: true` as well, so check the session list before
treating the session as gone (`codeman agent inbox --wait` does: exit 3 only
when the list lacks it, exit 2 otherwise). A client that hangs up during the wait
marks nothing seen. A **peek** with `?wait=` returns at once for as long as the
receiver has unseen mail, since only the receiver's read clears it; to watch
whether a worker has read its mail, poll the summary instead.
**Limits.** 200 messages, 16384 bytes (UTF-8) per message, 512 KiB of text per
inbox. A message over the per-message cap is `400 INVALID_INPUT`. A post that
would cross either inbox cap is `409 CONFLICT` and nothing is stored; the oldest
message is never dropped to make room. Messages expire 12 hours after their
`createdAt`, read or not.
**Summary.** `GET /api/v1/agent-inbox/summary` lists every session the caller
can see that has mail or a parked receiver: `pending`, `unseen`,
`oldestUnseenAt` (epoch ms, `null` when nothing is unseen) and `parked` (the
receiver itself is waiting on `?wait=` right now). Counts only, never text. It
is not part of the session object, so a post does not broadcast
`session:updated`.
**Lifetime.** An inbox is deleted with its session (`DELETE
/api/v1/sessions/:id` and every other delete path), not when a session is
detached. By default inboxes live in memory and a server restart empties them.
The synced setting `agentInboxPersistEnabled` (default off; Settings → Cross-session features)
writes them to `agent-inbox.json` in the data dir (mode `0600`, tmp file +
rename) and restores them at boot. Inboxes of sessions that did not come back
are pruned only after the session restore succeeded; a session on the
reboot-restore offer keeps its inbox, so a rebuild finds its mail. Switching the setting off
deletes the file and keeps what is in memory. The file is never read by
`/api/search`, and the file viewer refuses it.
```bash
# coordinator: leave a task for a worker
curl -s -X POST "$API/api/v1/sessions/$WORKER/inbox" \
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$(cat task.md)" '{text: $t}')"
# worker: wait for new mail, handle it, then ack by id
curl -s "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox?wait=300000" \
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" | jq '.data.messages'
curl -s -X POST "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox/ack" \
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \
-d '{"ids":["<id>"]}'
```
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
A `multipart/form-data` body with one `image` part. The file is written into the
@@ -1134,6 +1226,8 @@ A local case whose folder is the top of a git repository can keep **worktrees**
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
### System operations
The same multi-user rule covers the operations that act on the whole server. For a non-admin:
+25 -7
View File
@@ -18,7 +18,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### The prefix table on Codeman's socket holds only send-prefix
**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to `<prefix> send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C-<letter> detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide, so what the TUI binds for its attach applies to browser tabs as well: F1 and `C-d` detach a tab's client, and **`M-1`..`M-9` (`switch-client -t <session>`) move it onto another session**, the very thing this reset exists to stop. The browser keeps a typed Alt+digit out of the terminal (`terminal-ui.js` swallows it for its own tab switching), but Esc followed by a digit within tmux's `escape-time` (500 ms on tmux 3.4) arrives as the same `M-<digit>`. This lasts for the length of the attach, and indefinitely after a TUI that is killed before `restore()` runs. Not fixed here; scoping those bindings to the TUI's own client is the open follow-up. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`.
**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to `<prefix> send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. The same reduction is applied when Codeman creates the nested tmux behind a Docker case (`-L codeman-docker`) or an owned remote session (`-L codeman-remote`). Those servers set `prefix C-q` and used to keep tmux's stock prefix table, so inside the pane Ctrl+Q then `)` `(` `s` `w` or `L` ran `switch-client` / `choose-tree` on the inner server and moved its client onto a sibling session. `nestedPrefixTableResetCommands()` (bind `C-q send-prefix`, then `unbind-key -a -T prefix`, then bind it again, so a user config that left the prefix table empty cannot make `unbind-key -a` abort the `\;` chain) is appended to both create chains, before `remain-on-exit failed` on the remote chain so a remote tmux that rejects `failed` cannot drop it. Those sockets are dedicated to Codeman, so the server-global table is the right scope. A non-owned remote attach joins another Codeman's `-L codeman` and does not run it. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C-<letter> detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide. F1 and the held-Ctrl `C-d` still detach every client for the length of an attach (a way out, not a session move). **`M-1`..`M-9` do not move other clients.** `bindSwitchKey` binds them to `if-shell -F` (`tuiSwitchGuard`) so `switch-client` runs only when `#{client_tty}` is the TUI's terminal and `#{client_pid}` equals `@codeman-tui-pid`. A one-shot `client-attached[90]` hook (`tuiSwitchAttachHook`) sets that option as the TUI's client attaches, then removes itself. It is armed as the handoff's last tmux call before `spawnSync`, after the keys and the per-session bars, so only a TUI killed during the attach's own startup can leave that hook armed for its tty. A browser tab does not match, including Esc followed by a digit within `escape-time` (500 ms on tmux 3.4), which arrives as the same `M-<digit>`. A client that fails the guard gets the key back: the `if-shell` else argument is `send-keys M-<digit>`, which writes Esc and the digit into that client's own pane, so a vim `<Esc>2j` in a browser tab is not eaten by the root binding. If the TUI is killed before `restore()`, the binding can remain, but the stored pid is the dead client and the hook will not re-arm a pty that reuses the number, so the key never switches and every client gets it in its pane through the else branch. `restore()` removes the keys only while they still name this tty, and the next attach that claims the strip releases any of these bindings first. A second TUI attach replaces the keys (one binding per key). Escape-time on Codeman's socket stays at tmux's default: lowering it would change Esc delivery for every pane, and the guard already refuses other clients. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client, including the nested `C-q` table), `test/tui/tui-switch-guard.test.ts` (the guarded Alt keys against a second client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`.
### Reverse-proxy base path
@@ -89,6 +89,8 @@ File reads in a remote case are the second ssh surface (see Remote SSH cases bel
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids).
Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
@@ -221,7 +223,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
**The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture, and for a remote SSH session, whose local pane hosts the remote tmux client and always reports 0 while `full=1` reads the remote history), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the notice decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The one place `alternate_on` decides is a shell's AUTOMATIC load (`alt=recording`, see Full-scrollback replay): there the question is whether the capture holds the scrollback a nested tmux client keeps in its byte recording, not whether a pull can return more. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`.
**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite).
**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript, and the same memory is what stops it: a buffer a tail replay left at `baseY === 0` while the pane reports scrollback is unloaded, not hollow, so the gesture scrolls locally and the scroll-to-top pull (`_maybeRefetchFullHistory`) fetches `full=1` (see the hollow-pane rule under wheel forwarding). That pull skips the 60 s useless-pull latch for such a buffer (the latch's premise, a browser holding more than tmux, is gone) and drops a completion whose select generation moved during the fetch, since the session-id check alone cannot see a switch that came back (A to B to A); the refresh path records its count only past its owner check for the same reason, and no caller (the switch, the refresh, the pull) records an error envelope. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite).
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass without `alt=recording` (every `useFullHistory` pass; only shell loads send it) is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
@@ -243,9 +245,9 @@ Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Fi
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still paLine truncated
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow smalLine truncated
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|page-keys-dialog-open|page-keys-menu-open|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts.
@@ -688,6 +690,20 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
### Agent inbox
**Agent inbox** (discussion #558): a per-session mailbox one agent posts to and another reads, so a coordinator can hand a worker text without typing into its pane. Store `web/agent-inbox.ts` (pure, `sessionWaits`-style singleton, no IO), routes `routes/inbox-routes.ts`, CLI verbs `codeman agent post|inbox|ack`, opt-in snapshot `web/agent-inbox-persistence.ts`. Nothing in it writes to a pane, and it is kept out of `toState()` so a post does not fan out a `session:updated` to every browser; the counts live behind `GET /api/agent-inbox/summary` (owner-scoped, counts only, never text), which `codeman agent ls` merges into its INBOX column.
⚠️ **Read until ack.** A read removes nothing; the same messages come back until the receiver acks them BY ID (`POST …/inbox/ack`, idempotent: an id already gone is not an error). Acking by id rather than "everything I read" is what keeps a message posted between the read and the ack from being lost.
⚠️ **Seen and ack belong to the receiver.** The caller is whatever `resolveParentSessionId()` makes of `X-Codeman-Parent-Session` (live session, same owner as the receiver), never a body field (`InboxPostSchema` is strict). Only a read whose caller IS the receiver marks messages seen; anyone else's read is a peek. An ack from anyone else is a 403, not a silent no-op, so a coordinator never believes it cleared a worker's mail. In multi-user mode this stops a non-admin caller from naming another owner's session (an admin passes `canAccessOwned` for every session, so an admin can act as any session of the receiver's owner); in single-user mode every local process holds the same credentials, so the header is the client's own claim about itself, which is all the seen/peek split needs.
⚠️ **`?wait=` parks while nothing is UNSEEN**, not while nothing is pending: a receiver holding a seen-but-unacked message would otherwise get an immediate answer on every loop and spin. Inbox waiters are the registry's third waiter kind (`waitForInbox`/`notifyInbox`), so they count against the same per-session/owner/total caps and are ended by `cancelAll` like signal and output waiters. `notifyInbox` runs AFTER the store, so a woken reader finds the message. `parked` in the summary counts receiver waiters only.
⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock.
⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row, except a record whose tmux session the multiplexer still lists (`mux.getSession`): a detach keeps that pane, and the next boot re-adopts it under the same id even without a `state.json` record. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`.
### Voice dictation via Claude
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses.
@@ -895,7 +911,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
⚠️ **The arrow-key walk is the one place that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the thing the user steps with their eyes follows the paint.
⚠️ **The arrow-key walk and the phone swipe are the two places that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). That walk is `_visibleTabRows()`, and the phone swipe shares it: `SwipeHandler` calls `nextSession({ visual: true })` / `prevSession({ visual: true })`, which step through `_stripSessionOrder()` (the grouped tree's session rows via `_tabTreeItems()`, otherwise `_visibleTabRows()`, web tabs skipped), so a swipe lands on the tab drawn next to the current one in the case and state layouts, a sorted rail and named groups; Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep `sessionOrder`. An active session the list does not draw steps in at the first row (next) or the last (previous), and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the things the user steps with their eyes (the arrow keys and the swipe) follow the paint.
### Split-pane sessions
@@ -1029,7 +1045,7 @@ With `classic` nothing is left behind: no headings, no breaks, no inline order,
### Header stats styles
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. The opt-in host readings after MEM (GPU, VRAM, GPU MHz, CPU MHz: `.stat-item` elements that start `hidden` and are shown by `_updateHostSensors` in panels-ui.js) rely on `html[data-header-stats] .header-system-stats .stat-item[hidden]` in styles.css outranking the Tiles style's own `display` on `.stat-item`; keep that selector's specificity above any style rule that sets `display` on a stat item, or the hidden placeholders (`--%`) reappear. Those four carry `.stat-item--host`, which the header divider's density rule (`data-header-density`) excludes from its label hide: their values read like CPU's and MEM's, so the label is the only thing naming them. At Compact + `tiny` (rings only, values hidden) a reading with no ring is hidden outright rather than left an empty flex item: `.stat-item--ringless`, static on the two clocks and toggled by `_updateHostSensors` on VRAM while its total (and so its ring) is unknown. Tests: `test/header-host-sensors.browser.test.ts` (browser suite, not in the gate).
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
@@ -1131,6 +1147,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
⚠️ **The connect attempt has its own deadline, because the watchdog cannot see it.** An `EventSource` whose request never gets response headers (a phone resumed on a VPN or tailnet, a proxy that accepted the socket and went silent) stays `CONNECTING` without firing `open` or `error`, so `onerror` never schedules a retry and the status is never `connected` for `computeSseStale()` to judge: the page sat on "connecting" until a reload. `connectSSE()` therefore bounds each attempt by `SSE_CONNECT_TIMEOUT_MS` (15s, exported as `CodemanSseStale.CONNECT_TIMEOUT_MS` in constants.js; read once into `_sseConnectTimeoutMs`) and, if `open` has not fired, retries through `_handleSseConnectionFailure()`, the former `onerror` body, so a hung attempt gets the same backoff, status change and "Retrying in Ns" countdown as an errored one. ⚠️ The timer is cleared in exactly three places: at the top of `connectSSE()` **before** the offline early return (every path into `connectSSE()` supersedes the previous attempt; removing that clear fails the offline case in the test), on `open`, and in `_handleSseConnectionFailure()`. ⚠️ Both the timeout callback and `_handleSseConnectionFailure()` return early for an `EventSource` that has since been replaced, so a late error and the timeout never count one failure twice, and the timeout also skips a connection whose `readyState` is already `OPEN` before its `open` callback ran. ⚠️ The window `offline` handler deliberately leaves the timer running, since `navigator.onLine` can report offline on a working network; only a `connectSSE()` made while offline cancels it. Tests: `test/sse-connect-timeout.test.ts` (the real constants.js + app.js in a `vm` context with a fake `EventSource` and fake timers).
### PTY and browser terminal geometry
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it).Line truncated
@@ -1163,7 +1181,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **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 |
| **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_*`) |
+1 -1
View File
@@ -105,7 +105,7 @@ Keep the existing single-user branch untouched. Add a parallel multi-user branch
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so one flaky user behind a NAT cannot lock out the rest. As built, the per-username bucket is checked after the verify: it answers 429 to failed guesses once full and never denies a correct password, so it does not cap verified guesses. What caps them is a per-(username, IP) bucket checked before the verify (10 per pair), beside a per-IP pre-gate of 50 (#684).
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
+12 -2
View File
@@ -94,7 +94,10 @@ ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest'
set -t codeman-ssh-<id8> window-size latest \; \
bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
bind-key -T prefix C-q send-prefix \; \
set -t codeman-ssh-<id8> remain-on-exit failed'
```
Key points:
@@ -108,7 +111,14 @@ Key points:
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
tmux server's other sessions are untouched (#145 hardening). Note the
tmux server's other sessions are untouched (#145 hardening). The prefix
*table* is server-global, so the create chain reduces it to `C-q send-prefix`
(bind `send-prefix`, then `unbind-key -a -T prefix`, then bind it again). The
leading bind keeps an empty prefix table from aborting the chain:
`unbind-key -a` fails with "table prefix doesn't exist" and tmux drops every
later command, including `remain-on-exit failed`. Without that reduction, Ctrl+Q then `)` moves
the inner client onto a sibling session. A non-owned attach does not do this:
it joins the remote Codeman's own socket. Note the
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
socket** — they join sessions the remote's own Codeman manages, while owned
durable launches live on `-L codeman-remote`.
+17 -1
View File
@@ -149,7 +149,7 @@ by presence in a server‑side map, so they cannot be forged offline.
### Rate limiting / lockout recovery
Failed auth is tracked **per IP**: 10 failures → `429`, with a 15‑minute decay.
Failed logins are tracked **per IP**: 10 failures (`AUTH_FAILURE_MAX`) → `429`, with a 15‑minute decay. Multi-user mode replaces that with three buckets, all with the same decay: a wider per-IP **pre-gate** of 50 failures (`AUTH_IP_FAILURE_MAX`), checked before the scrypt verify, which bounds scrypt cost from one source and tolerates many users behind one egress IP; a **per-(username, IP)** gate of 10 failures (`AUTH_FAILURE_MAX`), also checked before the verify, which is what caps verified guesses against one account from one source; and a **per-username** bucket, checked after the verify, that answers `429` to failed guesses from the 10th on, from any IP (the count is incremented before the check). The last two exist in multi-user mode only. So the wider per-IP cap does not widen guessing against any one account from one IP, and other IPs cannot lock an account out.
The QR path has its own separate limiter.
The lockout check sits **after** the cookie/credential checks (step 4, not first).
@@ -158,6 +158,22 @@ immediately** even while an attacker is hammering the same IP — important beca
all traffic through a tunnel shares one source IP (loopback). Wrong credentials
are still counted and still hit the `429` at the threshold, so brute‑force
protection is unchanged.
The two multi-user pre-verify gates are the exception: once the per-IP pre-gate
or an account's (username, IP) gate trips, even a correct password from that IP
gets `429` until the decay (for the pair gate, only for that account; the same
account from another IP still logs in), which is why the per-IP cap is the
widest. The per-username bucket stays after the verify on purpose: checked
first, a username-wide lockout let throwaway IPs keep a known account (admin
included) locked out even with the right password.
Both pre-verify gates count a request the moment it passes them, before its
verify starts, so a burst of concurrent attempts cannot all slip through on one
stale count; a correct password then clears its slot with the rest, and a
failure is not counted a second time. A verify still in flight therefore holds a
slot, even one carrying the right password, but identical credentials from the
same IP that arrive during it share that verify, its slot and its outcome, and
skip both gates: they add no guess and no scrypt work. So a browser replaying its
cached login on many parallel requests after a restart (cookie sessions live in
memory) is not refused by the slots its own password filled.
---
+4 -1
View File
@@ -193,7 +193,10 @@ wears a bar across the top:
That is the **session strip**: the other sessions stay visible from inside a pane,
numbered exactly as the dashboard numbers them, with the one you are in inverted.
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. With
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. Those keys
run `switch-client` only for this terminal's tmux client, so a browser tab on the
same socket cannot use them to jump sessions, Esc followed by a digit included (that
tab's pane still receives the Esc and the digit). With
more sessions than fit, the strip shows a window around the current one and marks
each cut end with `…`; the way-out hint is measured first and always keeps its space.
+70 -41
View File
@@ -14,23 +14,52 @@ Codeman-side configuration:
- Which CLI the Run button should default to.
- Per-case toggles (Agent Teams, 1M Opus context).
- A **Default session group** (Case settings…): new sessions of the case land in that tab group of the
vertical rail unless you start them from a group's own *New session* or they follow the session that
spawned them. *Ungrouped* turns it off. It follows you across devices, never moves existing tabs, and a
vertical rail unless you start them from a group's own _New session_ or they follow the session that
spawned them. _Ungrouped_ turns it off. It follows you across devices, never moves existing tabs, and a
deleted group simply falls back to Ungrouped.
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
registration, and for a linked case that is all it removes.
**Renaming a case.** In the case manager (Settings, or the case picker's manage view) the pencil
button renames a case and its folder. A case under `~/codeman-cases` _is_ its folder name, so it
always moves. A linked case renames its name in the list only, unless you tick **Also rename the
folder on disk** in the rename dialog: then its real folder is renamed too, next to where it is.
Remote and Docker cases cannot be renamed. Renaming does the following for you:
- **Sessions are never moved from under you.** If live sessions have the folder open, the rename is
refused and lists them; you can then choose to stop them. That closes them the way closing their tabs
does (nothing is restarted, and the last one to close removes the files attached to prompts in the
case's `.codeman-uploads`). While a rename runs, Codeman holds the folder so no session can start
inside it half-moved. A session that is just being started there stops the rename, which says so;
trying again a moment later lists that session with the others.
- **A folder another program holds open is reported, not forced.** On Windows, SMB shares and some FUSE
mounts a directory with an open handle cannot be renamed; Codeman retries briefly, then says the folder
is in use and to close whatever has it (a terminal, an editor, a file browser, a sync client).
- **Claude Code's history follows.** It keys conversations by the folder's path, so the rename moves
`<claude config>/projects/<old path>` to the new path and `--resume` still finds them. When the new
path already has history (a case deleted earlier under that name leaves its history behind), the
conversations are merged into it without overwriting anything. Other CLIs'
history, Docker mounts and `git worktree` links that point at the old path are not updated. The
Resume list uses the renamed folder too, and the case's default session group follows the new name.
- **Not migrated.** Scheduled (cron) jobs keep the old `workingDir` and fail at their next run until you
edit them; Claude history of sessions started in a subfolder (including `.claude/worktrees/<name>`)
stays under the old path. Claude Code's own per-project settings in `~/.claude.json` (local-scope MCP
servers, allowed tools, the folder's trust) are keyed by the old path too, so the renamed case starts
without them: Codeman does not write another CLI's configuration here. In multi-user mode only an
admin's rename updates the shared case order and last used case.
- **The case order, the last used case and this browser's per-case options follow the new name.**
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
@@ -75,10 +104,10 @@ Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one pla
Where a case runs is **separate from** which CLI it runs. There are three locations:
| Location | What happens |
| -------------- | ------------------------------------------------------------------------------------------------------------ |
| **Local** | The default. tmux and the CLI run on the Codeman host. |
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| Location | What happens |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Local** | The default. tmux and the CLI run on the Codeman host. |
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
This matters because it is a common source of confusion: Docker is **not** an eleventh run
@@ -113,14 +142,14 @@ sessions never appear in a bare `tmux ls`.
## What persists
| Survives | Does not survive |
| -------------------------------------------- | --------------------------------------------------- |
| Closing the browser | `tmux -L codeman kill-server` |
| Losing the network | A machine reboot (tmux dies with it) |
| Restarting the Codeman server | Killing the session from the UI |
| `codeman web --stop` | |
| A dropped SSH link, for remote cases | |
| A container restart, for docker cases | |
| Survives | Does not survive |
| ------------------------------------- | ------------------------------------ |
| Closing the browser | `tmux -L codeman kill-server` |
| Losing the network | A machine reboot (tmux dies with it) |
| Restarting the Codeman server | Killing the session from the UI |
| `codeman web --stop` | |
| A dropped SSH link, for remote cases | |
| A container restart, for docker cases | |
Conversation history is a separate question: Claude transcripts live in `~/.claude/`, so a
conversation can be resumed even after the tmux session is gone. That is what the welcome
@@ -130,18 +159,18 @@ screen's **Resume Conversation** list offers.
Everything Codeman knows lives under `~/.codeman/`:
| File | Holds |
| ---------------------------------------- | -------------------------------------------------------------------- |
| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. |
| `settings.json` | User preferences that sync across your devices. |
| `mux-sessions.json` | tmux recovery data. |
| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. |
| `webviews.json` | Saved dashboard URLs. |
| `users.json` | Multi-user accounts, mode 0600. |
| `push-*.json` | Web push keys and subscriptions. |
| `certs/` | Self-signed TLS for `--https`. |
| File | Holds |
| ---------------------------------------- | --------------------------------------------------------------------------------------------- |
| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. |
| `settings.json` | User preferences that sync across your devices. |
| `mux-sessions.json` | tmux recovery data. |
| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. |
| `webviews.json` | Saved dashboard URLs. |
| `users.json` | Multi-user accounts, mode 0600. |
| `push-*.json` | Web push keys and subscriptions. |
| `certs/` | Self-signed TLS for `--https`. |
| `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). |
None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets
@@ -184,17 +213,17 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Vocabulary
| Term | Means |
| --------------- | ---------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator**| A phased plan driven across multiple agents. |
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
| **Instance** | One Codeman server with its own data directory and tmux socket. |
| Term | Means |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator** | A phased plan driven across multiple agents. |
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
| **Instance** | One Codeman server with its own data directory and tmux socket. |
## Read next
@@ -47,6 +47,9 @@ codeman agent read "$SID" --tail 3000 # terminal tail, ANSI
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent post "$SID" 'context for later, not a prompt' # stored in its inbox, not typed into the pane
codeman agent inbox --wait --timeout 300000 # your own inbox: blocks while nothing is new
codeman agent ack <msg-id> # remove what you handled (read never removes)
codeman agent rm "$SID" # any session except this one
```
@@ -56,6 +59,14 @@ codeman agent rm "$SID" # any session except t
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
prompt is output too, so ask for the marker in halves and wait on the joined form.
- **`post`, `inbox`, `ack`** are the agent inbox: a message for another session that starts
no turn. `post` stores ONE quoted argument (multi-line is fine, so `"$(cat task.md)"` works)
and types nothing into the pane. `inbox` reads this session's mail, or peeks at another's
with an id, and removes nothing; `ack <id>…` removes what you handled. `inbox --wait`
blocks while nothing is new, so mail you read but did not ack does not wake it again.
`ls` shows each session's mail in an INBOX column. Mail lives in memory unless
**Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)),
and goes when its session is deleted.
- **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`.
+1 -1
View File
@@ -8,7 +8,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| Shortcut | Action |
| ------------------------------- | --------------------------------------------------------------- |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. On the **New session** row, `Left` / `Right` change the agent it starts. |
| `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
+3 -2
View File
@@ -37,8 +37,9 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s
the hinge, and opening or closing the device is treated as the device changing shape, never
as the keyboard appearing.
**Swipe left and right** on the terminal to switch sessions. A visible text selection pauses
the swipe, and a terminal wider than the screen pans sideways instead.
**Swipe left and right** on the terminal to switch sessions, in the order the tabs are shown
(a grouped or sorted tab layout included). A visible text selection pauses the swipe, and a
terminal wider than the screen pans sideways instead.
## The home screen
+6
View File
@@ -239,6 +239,12 @@ minute decay.
A valid cookie or a correct password recovers immediately even while an attacker is hammering
the same IP, which matters because all tunnel traffic arrives from one loopback address.
Multi-user mode is different: there the per-IP cap is 50, each account allows ten failed
guesses from one IP, and both are checked before the password. Behind a tunnel every client
shares the loopback address, so past 50 failures in total a correct password waits for the
decay for everyone, and past ten failures for one account it waits for that account (the
account still logs in from another IP). A valid cookie keeps working throughout.
## Terminal alternatives
You do not have to use a browser. `codeman tui` is a full-screen session dashboard that
+1 -1
View File
@@ -53,7 +53,7 @@ When authentication is enabled:
| ------------------- | ------------------------------------------------------------------------------------------------ |
| **HTTP Basic** | `CODEMAN_USERNAME` (default `admin`) and `CODEMAN_PASSWORD`. |
| **Session cookie** | A 256-bit opaque token validated server side, so it cannot be forged offline. 24 hours, extended on activity, with a device-context audit trail. |
| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. |
| **Rate limiting** | Ten failed attempts per IP produce a `429` with a 15 minute decay. A correct password or valid cookie recovers immediately even under attack, which matters because all tunnel traffic shares one loopback address. In multi-user mode the per-IP cap is 50, and each account allows ten failed guesses from any one IP. Both are checked before the password, so past either a correct password from that IP also waits for the decay (the account still logs in from another IP, and a valid cookie keeps working). From the tenth failed guess on, an account's wrong guesses get a `429` from every IP, which never refuses a correct password. |
| **QR auth** | Single-use 60-second tokens with their own separate rate limiter, so a mistyped password cannot lock out QR login. |
| **Hook endpoints** | The hook and telemetry endpoints skip Basic auth because they are called from localhost by the CLI, but when auth is on, that bypass additionally requires a per-instance hook secret. |
+17
View File
@@ -65,6 +65,16 @@ Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Life
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
**Host readings** (opt-in, both off by default and per device): **GPU Stats** adds three items after CPU and MEM: GPU load, graphics memory and the GPU clock,
and **CPU MHz** adds the live CPU clock. Both sets of items live inside **System Stats**, so they show only
while that chip is on, and nothing is fetched while it is off. An item appears only when the
host reports it, so a machine with no readable GPU shows nothing extra. Sources: `nvidia-smi` or the amdgpu
sysfs files for the GPU; inside WSL, the Windows performance counters through `typeperf.exe`, which cover
any vendor (an Intel GPU gives load and memory in use, but Windows publishes no clock for it, and its memory
total is taken as half of Windows' RAM, the limit Windows applies to integrated GPUs); and
`scaling_cur_freq`, `/proc/cpuinfo` or, inside WSL, the Windows `Processor Frequency` counter for the CPU
clock. Nothing is read, and `nvidia-smi` and `typeperf.exe` are never run, while both are off.
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
@@ -89,6 +99,13 @@ way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
This section also holds background-agent tracking, including whether to track agents for
every session or only the active tab.
**Keep agent inbox across restarts** (under **Cross-session features**, synced, off by
default) saves the pending `codeman agent post` messages to `agent-inbox.json` in the data
dir, readable by you only, so they survive a server restart. Off, the agent inbox lives in
memory and a restart empties it; switching it off deletes the file and keeps what is in
memory. Messages expire 12 hours after they were posted either way. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
### Appearance
| Setting | Notes |
+8
View File
@@ -178,6 +178,14 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w
Hiding System Stats or Plan Usage still hides them in every style.
The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar
(it wraps onto a second line and the usage readings shed their labels, then their provider names,
then, in the Compact style, their percentages), left to widen it. The GPU, VRAM and clock readings
keep their labels, since their values look like CPU's and MEM's; at the narrowest Compact step a
clock, which has no ring, is hidden. Arrow keys resize it, and
double-click or Home resets it. It stops at the widest reading, the width is kept per device, and it
is not shown on phones, in the sidebar layout, in the vertical rail or in a solo window.
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.44.0",
"version": "1.45.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.44.0",
"version": "1.45.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.44.0",
"version": "1.45.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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.44.0",
"version": "1.45.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+7 -4
View File
@@ -35,7 +35,8 @@ This skill works the same whichever CLI you are: Codeman puts it where each CLI
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `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.
@@ -119,9 +120,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -602,6 +603,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
| leave a message a session reads when it is ready, any mode, multi-line | `codeman agent post <id> "…"` (`POST .../inbox`). Nothing is typed into its pane and no turn starts; the receiver reads with `codeman agent inbox --wait` and removes with `codeman agent ack <id>`. An idle receiver never looks on its own: `codeman agent ls` shows unread mail, then `send` it one line | [§5.15](reference/verbs.md#515-agent-inbox) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -705,6 +707,7 @@ these**; open the one row you actually hit.
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
## 6. Setup and auth
+3 -3
View File
@@ -18,9 +18,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -35,8 +35,13 @@ only and can never drive a session.
with a parse error and `.errorCode` is simply absent (see
[symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one
IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a
failing credential in a loop**: you will lock the address out of the login path for
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user
mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and each account allows 10 failed
guesses per IP; both are checked before the password, so past either even a correct
password from that address gets the 429 (for the per-account one, only for that
account). From the 10th failed guess on, an account's wrong guesses also get the 429
from every address, but a correct password still logs in. **Never retry a failing
credential in a loop**: you will lock the address out of the login path for
everything, including the user's browser through a tunnel (tunneled traffic arrives as
127.0.0.1, so one bucket covers it all).
@@ -608,6 +613,22 @@ Approving a permission dialog on your own is exactly the laundering this skill f
can access, which includes the user's own working sessions. An approval belonging to one
of those is something you **report**, never something you answer.
### Agent inbox: hand a worker text without typing into its pane
Stored, not typed: the receiver reads it when it asks. Semantics, CLI form and the
receiver loop: `verbs.md` §5.15.
| Task | Call |
|------|------|
| post to a session's inbox | `POST /api/v1/sessions/:id/inbox` body `{"text":"…"}` → `.data.message.{id,from}`; 409 = inbox full, nothing stored |
| read (yours marks seen, anyone else's is a peek) | `GET /api/v1/sessions/:id/inbox` → `.data.{receiver,pending,unseen,messages[]}` |
| block until something unseen arrives | `GET /api/v1/sessions/:id/inbox?wait=60000` → `.data.wait.{posted,timedOut,ended}` |
| remove what you handled (receiver only, idempotent) | `POST /api/v1/sessions/:id/inbox/ack` body `{"ids":["…"]}`; 403 from anyone else |
| counts per session, never text | `GET /api/v1/agent-inbox/summary` → `.data.sessions[id].{pending,unseen,oldestUnseenAt,parked}` |
`from` and "who is the receiver" both come from the `X-Codeman-Parent-Session` header the
preamble's `CURL` sends, never from the body.
### The wait primitives
Three bounded long-polls. Shared semantics:
@@ -801,7 +822,7 @@ response echoes.
| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per account per IP) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
Case creation is **uncapped**, which is the one place restraint has to come from you:
every `quick-start` with a new `caseName` creates a real directory on the user's disk.
@@ -764,3 +764,50 @@ Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unifie
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
### 5.15 Agent inbox
Every session has an inbox. A message posted there is stored, not typed: the receiver's
pane is untouched, no turn starts, and the text may span several lines. The receiver
reads when it is ready. Use it for results a worker hands back, for a follow-up task a
busy worker should pick up after its current one, and for anything longer than one
composer line. Use `send` when you want a turn to start now.
```bash
codeman agent post "$WORKER" "$(cat task.md)" # ONE quoted argument; prints pending/unseen
codeman agent inbox --wait --timeout 300000 # your own inbox; blocks while nothing is new
codeman agent ack <id> [<id>…] # remove what you handled, by the full id inbox printed
codeman agent inbox "$WORKER" # peek at someone else's: marks nothing
codeman agent ls # INBOX column: pending (new), parked
```
The same over HTTP: `POST /api/v1/sessions/:id/inbox {"text":…}`, `GET
/api/v1/sessions/:id/inbox?wait=<ms>`, `POST /api/v1/sessions/:id/inbox/ack {"ids":[…]}`,
`GET /api/v1/agent-inbox/summary`. The preamble's `CURL` already sends
`X-Codeman-Parent-Session`, which is how the server knows who you are.
Rules that matter:
- **Reading removes nothing.** Every read returns every pending message until you ack
it, so a crash between reading and doing loses nothing. Ack once the work is done,
not when you have read it: a sender looking at `ls` sees unacked mail as not done.
- **Only the receiver's own read counts.** Your read of your own inbox marks messages
seen (`seen: true` on the next read); reading another session's inbox is a peek and
changes nothing for it. Only the receiver can ack (403 otherwise).
- **`--wait` waits for something new.** It returns at once when there is unseen mail
and otherwise blocks until a post arrives; seen but unacked messages come back with it
and do not end the wait. Timeout is exit 2, and so is a wait the server cut short
(a restart): loop again. Exit 3 means the session list no longer has the session.
`--wait` on someone else's inbox returns at once while they have unseen mail (only
their read clears it); check `ls` instead.
- **The sender is resolved, not claimed.** `from` is the session the header resolves to,
or `null` (the user). In single-user mode that is a hint, since every local process
holds the same credentials. In multi-user mode it is checked against the owner;
an admin can still act as any session of the receiver's owner.
- **Full means refused.** 200 messages or 512 KiB per inbox, 16 KiB per message. A full
inbox answers 409 and stores nothing; the oldest is never dropped. Messages expire 12
hours after they were posted.
- **Nobody is woken.** An idle receiver does not look at its inbox by itself, and the
server never types into its pane. When `ls` shows a worker idle with unread mail and
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
+2
View File
@@ -120,6 +120,7 @@ run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --m
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
run('minify header-divider.js', 'npx esbuild dist/web/public/header-divider.js --minify --outfile=dist/web/public/header-divider.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
@@ -149,6 +150,7 @@ console.log('\n[build] content-hash cache busting');
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
'header-divider.js',
'terminal-ui.js',
'respawn-ui.js',
'ralph-panel.js',
+7 -4
View File
@@ -35,7 +35,8 @@ This skill works the same whichever CLI you are: Codeman puts it where each CLI
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `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.
@@ -119,9 +120,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -602,6 +603,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
| leave a message a session reads when it is ready, any mode, multi-line | `codeman agent post <id> "…"` (`POST .../inbox`). Nothing is typed into its pane and no turn starts; the receiver reads with `codeman agent inbox --wait` and removes with `codeman agent ack <id>`. An idle receiver never looks on its own: `codeman agent ls` shows unread mail, then `send` it one line | [§5.15](reference/verbs.md#515-agent-inbox) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -705,6 +707,7 @@ these**; open the one row you actually hit.
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
## 6. Setup and auth
+3 -3
View File
@@ -18,9 +18,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
+24 -3
View File
@@ -35,8 +35,13 @@ only and can never drive a session.
with a parse error and `.errorCode` is simply absent (see
[symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one
IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a
failing credential in a loop**: you will lock the address out of the login path for
minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user
mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and each account allows 10 failed
guesses per IP; both are checked before the password, so past either even a correct
password from that address gets the 429 (for the per-account one, only for that
account). From the 10th failed guess on, an account's wrong guesses also get the 429
from every address, but a correct password still logs in. **Never retry a failing
credential in a loop**: you will lock the address out of the login path for
everything, including the user's browser through a tunnel (tunneled traffic arrives as
127.0.0.1, so one bucket covers it all).
@@ -608,6 +613,22 @@ Approving a permission dialog on your own is exactly the laundering this skill f
can access, which includes the user's own working sessions. An approval belonging to one
of those is something you **report**, never something you answer.
### Agent inbox: hand a worker text without typing into its pane
Stored, not typed: the receiver reads it when it asks. Semantics, CLI form and the
receiver loop: `verbs.md` §5.15.
| Task | Call |
|------|------|
| post to a session's inbox | `POST /api/v1/sessions/:id/inbox` body `{"text":"…"}` → `.data.message.{id,from}`; 409 = inbox full, nothing stored |
| read (yours marks seen, anyone else's is a peek) | `GET /api/v1/sessions/:id/inbox` → `.data.{receiver,pending,unseen,messages[]}` |
| block until something unseen arrives | `GET /api/v1/sessions/:id/inbox?wait=60000` → `.data.wait.{posted,timedOut,ended}` |
| remove what you handled (receiver only, idempotent) | `POST /api/v1/sessions/:id/inbox/ack` body `{"ids":["…"]}`; 403 from anyone else |
| counts per session, never text | `GET /api/v1/agent-inbox/summary` → `.data.sessions[id].{pending,unseen,oldestUnseenAt,parked}` |
`from` and "who is the receiver" both come from the `X-Codeman-Parent-Session` header the
preamble's `CURL` sends, never from the body.
### The wait primitives
Three bounded long-polls. Shared semantics:
@@ -801,7 +822,7 @@ response echoes.
| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per account per IP) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
Case creation is **uncapped**, which is the one place restraint has to come from you:
every `quick-start` with a new `caseName` creates a real directory on the user's disk.
+47
View File
@@ -764,3 +764,50 @@ Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unifie
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
### 5.15 Agent inbox
Every session has an inbox. A message posted there is stored, not typed: the receiver's
pane is untouched, no turn starts, and the text may span several lines. The receiver
reads when it is ready. Use it for results a worker hands back, for a follow-up task a
busy worker should pick up after its current one, and for anything longer than one
composer line. Use `send` when you want a turn to start now.
```bash
codeman agent post "$WORKER" "$(cat task.md)" # ONE quoted argument; prints pending/unseen
codeman agent inbox --wait --timeout 300000 # your own inbox; blocks while nothing is new
codeman agent ack <id> [<id>…] # remove what you handled, by the full id inbox printed
codeman agent inbox "$WORKER" # peek at someone else's: marks nothing
codeman agent ls # INBOX column: pending (new), parked
```
The same over HTTP: `POST /api/v1/sessions/:id/inbox {"text":…}`, `GET
/api/v1/sessions/:id/inbox?wait=<ms>`, `POST /api/v1/sessions/:id/inbox/ack {"ids":[…]}`,
`GET /api/v1/agent-inbox/summary`. The preamble's `CURL` already sends
`X-Codeman-Parent-Session`, which is how the server knows who you are.
Rules that matter:
- **Reading removes nothing.** Every read returns every pending message until you ack
it, so a crash between reading and doing loses nothing. Ack once the work is done,
not when you have read it: a sender looking at `ls` sees unacked mail as not done.
- **Only the receiver's own read counts.** Your read of your own inbox marks messages
seen (`seen: true` on the next read); reading another session's inbox is a peek and
changes nothing for it. Only the receiver can ack (403 otherwise).
- **`--wait` waits for something new.** It returns at once when there is unseen mail
and otherwise blocks until a post arrives; seen but unacked messages come back with it
and do not end the wait. Timeout is exit 2, and so is a wait the server cut short
(a restart): loop again. Exit 3 means the session list no longer has the session.
`--wait` on someone else's inbox returns at once while they have unseen mail (only
their read clears it); check `ls` instead.
- **The sender is resolved, not claimed.** `from` is the session the header resolves to,
or `null` (the user). In single-user mode that is a hint, since every local process
holds the same credentials. In multi-user mode it is checked against the owner;
an admin can still act as any session of the receiver's owner.
- **Full means refused.** 200 messages or 512 KiB per inbox, 16 KiB per message. A full
inbox answers 409 and stores nothing; the oldest is never dropped. Messages expire 12
hours after they were posted.
- **Nobody is woken.** An idle receiver does not look at its inbox by itself, and the
server never types into its pane. When `ls` shows a worker idle with unread mail and
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
+226
View File
@@ -0,0 +1,226 @@
/**
* @fileoverview Renaming a case (its name and its folder) without losing what points at the folder.
*
* A case folder is held in more places than an open file. A running agent has it as its working
* directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history
* by the folder's path (`<config>/projects/<path with every non-alphanumeric as "-">`), so a moved
* folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an
* open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of
* that which are not route logic:
*
* - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
* folder while it is being moved, and two renames from overlapping;
* - the record of sessions constructed but not yet registered (`isCaseSessionStarting`), which a
* rename refuses to move a folder under, since it can neither list nor stop them;
* - the move itself, retried a few times for a transient lock and turned into a message that says
* what to do when it is a real one;
* - carrying Claude Code's history for the folder over to the new path.
*
* Pure of the server: every function takes the paths it works on.
*
* @module case-rename
*/
import fs from 'node:fs/promises';
import { dirname, join, resolve, sep } from 'node:path';
import { claudeProjectsDir } from './utils/claude-transcript.js';
export type CaseRenameErrorCode =
| 'INVALID' // a name that is not allowed, or the same name
| 'EXISTS' // the new name or folder is already taken
| 'BUSY' // another rename (or a session being stopped) holds the folder
| 'LOCKED' // the OS refused the move: something still has the folder open
| 'CROSS_DEVICE' // the move would cross filesystems
| 'UNSUPPORTED'; // remote and Docker cases
export class CaseRenameError extends Error {
constructor(
readonly code: CaseRenameErrorCode,
message: string
) {
super(message);
this.name = 'CaseRenameError';
}
}
const locked = new Set<string>();
const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep);
/** True when `path` is, or is inside, a folder that is being renamed right now. */
export function isCasePathLocked(path: string): boolean {
const target = resolve(path);
for (const held of locked) {
if (within(target, held)) return true;
}
return false;
}
/**
* Sessions constructed but not yet in the server's session map, where the rename route looks for the
* sessions it must list or stop. A create awaits a few things between `new Session` and that map (the
* owner's layout lock, a settings read), and a rename that ran in that gap neither saw nor stopped the
* session, moved the folder, and left it to start in a path that no longer existed.
*/
const starting = new Map<object, { path: string; until: number }>();
/** A session that never reaches the map (a create that failed, a one-shot prompt) stops counting after this. */
const STARTING_TTL_MS = 30000;
const dropExpiredStarts = (now: number): void => {
for (const [session, entry] of starting) {
if (entry.until <= now) starting.delete(session);
}
};
/** `session` (any object standing for it) is being created with `workingDir`; `Session`'s constructor calls this. */
export function noteCaseSessionStarting(session: object, workingDir: string, now = Date.now()): void {
dropExpiredStarts(now);
starting.set(session, { path: resolve(workingDir), until: now + STARTING_TTL_MS });
}
/** `session` is in the server's session map (or was stopped): the rename route sees it from here on. */
export function caseSessionSettled(session: object): void {
starting.delete(session);
}
/** True when a session is being created at or inside `path`, so a rename of it must wait. */
export function isCaseSessionStarting(path: string, now = Date.now()): boolean {
dropExpiredStarts(now);
const root = resolve(path);
for (const entry of starting.values()) {
if (within(entry.path, root)) return true;
}
return false;
}
/**
* Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside
* them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory
* inside a held folder, so nothing starts in a case that is half-moved.
*/
export async function withCaseLock<T>(from: string, to: string, fn: () => Promise<T>): Promise<T> {
const held = [resolve(from), resolve(to)];
for (const p of held) {
for (const other of locked) {
if (within(p, other) || within(other, p)) {
throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.');
}
}
}
for (const p of held) locked.add(p);
try {
return await fn();
} finally {
for (const p of held) locked.delete(p);
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
/**
* `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a
* handle another process is closing, an antivirus scan) before it is reported as locked.
* `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first.
*/
export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise<void> {
if (
await fs.lstat(to).then(
() => true,
() => false
)
) {
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
}
for (let i = 0; ; i++) {
try {
await fs.rename(from, to);
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (code === 'EXDEV') {
throw new CaseRenameError(
'CROSS_DEVICE',
'The new folder would be on a different drive or mount; it is not moved.'
);
}
if (LOCK_CODES.has(code)) {
if (i < attempts - 1) {
await sleep(delayMs * (i + 1));
continue;
}
throw new CaseRenameError(
'LOCKED',
`The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` +
'(an editor, a file browser or a sync client) and try again.'
);
}
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
}
throw err;
}
}
}
/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */
export function claudeProjectDirName(path: string): string {
return resolve(path).replace(/[^a-zA-Z0-9]/g, '-');
}
/**
* Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list
* still find the conversations. Returns whether anything moved.
*
* The new path can already have history: Claude Code never removes a project directory, and deleting a
* case does not either, so a case deleted earlier under the new name leaves one behind. The old history is
* then merged into it entry by entry (conversations are UUID-named, so they do not collide), never
* overwriting: an entry both directories have (a `memory/` folder, say) stays at the old path. A merge
* only goes between two real directories, never through a symlink.
*/
export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise<boolean> {
const projects = claudeProjectsDir(configDir);
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
if (from === to) return false;
const fromStat = await fs.lstat(from).catch(() => null);
if (!fromStat) return false;
const toStat = await fs.lstat(to).catch(() => null);
if (!toStat) {
try {
await fs.rename(from, to);
return true;
} catch {
return false; // history is a convenience; the folder move already succeeded
}
}
if (!fromStat.isDirectory() || !toStat.isDirectory()) return false;
let moved = false;
for (const entry of await fs.readdir(from).catch(() => [] as string[])) {
const target = join(to, entry);
if (
await fs.lstat(target).then(
() => true,
() => false
)
)
continue;
try {
await fs.rename(join(from, entry), target);
moved = true;
} catch {
/* left at the old path, like an entry both have */
}
}
await fs.rmdir(from).catch(() => {}); // succeeds only once nothing was left behind
return moved;
}
/** Replace `from` by `to` in a list, keeping its position (the case order). */
export function renameInOrder(order: unknown, from: string, to: string): string[] | null {
if (!Array.isArray(order) || !order.includes(from)) return null;
return order.map((n) => (n === from ? to : n)) as string[];
}
/** The folder a linked case moves to: its own parent, the new name. */
export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName);
+272 -13
View File
@@ -3,7 +3,8 @@
* inside a Codeman session, in every CLI mode.
*
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
* `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
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
@@ -190,6 +191,22 @@ export function stripAnsi(text: string): string {
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/**
* Inbox message text for a terminal. Another session wrote it, so an escape sequence
* in it would otherwise reach the reader's terminal (retitle it, or move the cursor
* over the `from` line above it). ANSI sequences go as in `read --tail`, and every
* control character left (C0 other than newline and tab, DEL, C1) is shown as `\xHH`,
* so a bare `\r` or backspace cannot overwrite the header either. A CRLF line end
* stays a line end. `--json` prints the text as stored.
*/
export function terminalSafeText(text: string): string {
return (
stripAnsi(text.replace(/\r\n/g, '\n'))
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
);
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
if (raw === undefined) return fallback;
@@ -416,15 +433,55 @@ export async function resolveSessionId(
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
/** One session's row in `GET /api/agent-inbox/summary`. */
export interface InboxSummaryRow {
pending: number;
unseen: number;
oldestUnseenAt: number | null;
parked: boolean;
}
/**
* The inbox summary, or undefined when it cannot be had (a server without the route,
* a transient error). `ls` must still list sessions then, so this never fails the verb.
*/
async function fetchInboxSummary(deps: AgentDeps): Promise<Record<string, InboxSummaryRow> | undefined> {
try {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/agent-inbox/summary' });
if (!res.json?.success) return undefined;
return (res.json.data as { sessions?: Record<string, InboxSummaryRow> } | undefined)?.sessions ?? {};
} catch {
return undefined;
}
}
/** The INBOX cell of `ls`: `3 (1 new)`, `parked`, both, or empty. */
export function inboxCell(row: InboxSummaryRow | undefined): string {
if (!row) return '';
const parts: string[] = [];
if (row.pending > 0) parts.push(row.unseen > 0 ? `${row.pending} (${row.unseen} new)` : String(row.pending));
if (row.parked) parts.push('parked');
return parts.join(', ');
}
/** `agent ls` — every session the caller can see, self marked, with its inbox counts. */
export async function agentLs(deps: AgentDeps): Promise<number> {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
const [res, inbox] = await Promise.all([
deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' }),
fetchInboxSummary(deps),
]);
if (!res.json?.success) return fail(deps, describeFailure(res));
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
if (deps.json) {
emitJson(
deps,
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
sessions.map((s) => ({
...s,
self: isSelfSession(deps.ctx.selfId, s.id),
// null = empty inbox and not parked. The key is absent when the server did
// not answer the summary, so "no data" never reads as "no mail".
...(inbox ? { inbox: inbox[s.id] ?? null } : {}),
}))
);
return EXIT.ok;
}
@@ -432,17 +489,22 @@ export async function agentLs(deps: AgentDeps): Promise<number> {
deps.io.out(palette.muted('(no sessions)'));
return EXIT.ok;
}
const rows = sessions.map((s) => [
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
s.id.slice(0, 8),
s.mode ?? '?',
s.status ?? '?',
s.name || s.workingDir || '',
]);
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
const rows = sessions.map((s) => {
const row = [isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ', s.id.slice(0, 8), s.mode ?? '?', s.status ?? '?'];
if (inbox) row.push(inboxCell(inbox[s.id]));
row.push(s.name || s.workingDir || '');
return row;
});
const header = inbox ? [' ', 'ID', 'MODE', 'STATUS', 'INBOX', 'NAME'] : [' ', 'ID', 'MODE', 'STATUS', 'NAME'];
deps.io.out(table([header, ...rows], { gap: 2 }));
deps.io.out(
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
);
if (inbox) {
deps.io.out(
palette.muted('INBOX: pending (unseen), parked = the session is waiting on `agent inbox --wait` right now.')
);
}
return EXIT.ok;
}
@@ -790,6 +852,158 @@ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Agent inbox
// ─────────────────────────────────────────────────────────────────────────────
/** A message as `GET …/inbox` hands it out. */
export interface InboxMessageRow {
id: string;
from: string | null;
text: string;
createdAt: number;
seen: boolean;
}
/**
* The message `post` was given, which must be ONE argument, as with `send`: several
* would be an unquoted `$(cat notes.md)` split by the shell, and joining the pieces
* with spaces loses its newlines. Unlike `send`, newlines inside the one argument are
* fine: the inbox stores text as data and never types it.
*/
export function postTextFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return { error: `refusing: the message must be ONE argument, got ${words.length} — quote it` };
}
/** `agent post` — store a message in another session's inbox. Never types into its pane. */
export async function agentPost(deps: AgentDeps, options: { id: string; text: string }): Promise<number> {
if (options.text.length === 0) return fail(deps, 'refusing: empty message', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
body: { text: options.text },
});
// 409 = the receiver's inbox is full; nothing was stored. Passed through as an error.
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { message?: InboxMessageRow; pending?: number; unseen?: number } | undefined;
if (deps.json) emitJson(deps, data ?? {});
else
deps.io.out(
palette.ok(
`${GLYPH.ok} posted to ${target.id.slice(0, 8)} (${data?.pending ?? '?'} pending, ${data?.unseen ?? '?'} unseen)`
)
);
return EXIT.ok;
}
export interface InboxReadOptions {
/** Another session's inbox (a peek); omitted = this session's own. */
id?: string;
/** Long-poll budget in ms; the read parks while nothing is unseen. */
waitMs?: number;
}
/** True only when the session list was read and does not contain `id`. */
async function sessionIsGone(deps: AgentDeps, id: string): Promise<boolean> {
try {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return false;
return !((res.json.data as SessionRow[] | undefined) ?? []).some((s) => s.id === id);
} catch {
return false;
}
}
/** Relative age for humans: `12s`, `4m`, `3h`. */
function age(createdAt: number, now: number): string {
const s = Math.max(0, Math.round((now - createdAt) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
return `${Math.round(s / 3600)}h`;
}
/**
* `agent inbox` — read this session's inbox (or peek at another's). Removes nothing:
* a message stays until `agent ack <id>`. Only reading your OWN inbox marks messages
* seen. Exit 0 with mail, 2 when a `--wait` ran out with nothing unseen or the server
* cut it short (restart, detach), 3 when the session is gone from the list.
*/
export async function agentInboxRead(deps: AgentDeps, options: InboxReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id ?? deps.ctx.selfId);
if ('error' in target) return fail(deps, target.error, target.code);
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inbox`,
query: { wait: options.waitMs },
timeoutMs: options.waitMs !== undefined ? options.waitMs + 10_000 : undefined,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| {
receiver?: boolean;
messages?: InboxMessageRow[];
pending?: number;
unseen?: number;
wait?: { timedOut?: boolean; ended?: boolean };
}
| undefined;
const messages = data?.messages ?? [];
// `ended` is also what a server restart or a detach answers. Exit 3 tells a receiver
// loop to stop, so it needs the session list to confirm the session is gone; while
// it is still listed (or the list cannot be had, as during a restart) the wait was
// only cut short, which is exit 2: loop again.
const gone = data?.wait?.ended ? await sessionIsGone(deps, target.id) : false;
const code = gone ? EXIT.dead : data?.wait?.timedOut || data?.wait?.ended ? EXIT.timeout : EXIT.ok;
if (deps.json) {
emitJson(deps, data ?? {});
return code;
}
const now = (deps.now ?? Date.now)();
for (const m of messages) {
const sender = m.from ? m.from.slice(0, 8) : 'user';
const tag = m.seen ? palette.muted('seen') : palette.emph('new');
deps.io.out(palette.muted(`── ${m.id} from ${sender}, ${age(m.createdAt, now)} ago, `) + tag);
deps.io.out(terminalSafeText(m.text));
}
if (gone) {
deps.io.err(palette.err(`${GLYPH.fail} the session went away during the wait`));
} else if (data?.wait?.ended) {
deps.io.err(palette.muted('(the server ended the wait early, e.g. a restart; exit 2, wait again)'));
} else if (messages.length === 0) {
deps.io.err(palette.muted(data?.wait?.timedOut ? '(no mail; wait timed out, exit 2)' : '(no mail)'));
} else if (data?.receiver) {
deps.io.err(
palette.muted(
`${messages.length} pending. Nothing is removed by reading: when done, \`codeman agent ack <id>…\` (full ids above).`
)
);
} else {
deps.io.err(palette.muted(`${messages.length} pending (peek: nothing was marked seen).`));
}
return code;
}
/** `agent ack` — remove messages from THIS session's inbox by id. Idempotent. */
export async function agentAck(deps: AgentDeps, options: { ids: string[] }): Promise<number> {
const ids = options.ids.map((id) => id.trim()).filter(Boolean);
if (ids.length === 0) return fail(deps, 'refusing: no message ids', EXIT.refused);
const self = await resolveSessionId(deps, deps.ctx.selfId);
if ('error' in self) return fail(deps, self.error, self.code);
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(self.id)}/inbox/ack`,
body: { ids },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { removed?: number; pending?: number } | undefined;
if (deps.json) emitJson(deps, data ?? {});
else deps.io.out(palette.ok(`${GLYPH.ok} acked ${data?.removed ?? 0} (${data?.pending ?? '?'} still pending)`));
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commander wiring
// ─────────────────────────────────────────────────────────────────────────────
@@ -833,7 +1047,9 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P
export function registerAgentCommands(program: Command): Command {
const agent = program
.command('agent')
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
.description(
'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack'
);
agent
.command('ls')
@@ -977,6 +1193,49 @@ export function registerAgentCommands(program: Command): Command {
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
);
agent
.command('post <id> <text...>')
.description(
"Leave a message in another session's inbox (ONE quoted argument; multi-line is fine). Nothing is typed into its pane: it reads with `agent inbox`"
)
.option('--json', 'Machine-readable output')
// A message is often a file (`"$(cat task.md)"`) and markdown starts with `- ` or
// `---` often enough; without this commander takes it for an unknown option.
// `--json` and `-h` are still options: text that is exactly one goes after `--`.
.allowUnknownOption()
.action((id: string, words: string[], options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => {
const message = postTextFromArgs(words);
if ('error' in message) return Promise.resolve(fail(deps, message.error, EXIT.refused));
return agentPost(deps, { id, text: message.text });
})
);
agent
.command('inbox [id]')
.description(
"Read this session's inbox (or peek at another's with [id]). Reading removes nothing; `agent ack` does. --wait blocks while nothing is new: exit 2 on timeout"
)
.option('-w, --wait', 'Block until a new (unseen) message arrives')
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action((id: string | undefined, options: { wait?: boolean; timeout?: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentInboxRead(deps, {
id,
waitMs: options.wait ? parsePositiveInt(options.timeout, DEFAULT_WAIT_MS) : undefined,
})
)
);
agent
.command('ack <ids...>')
.description("Remove messages from this session's inbox by id, once handled (unknown ids are not an error)")
.option('--json', 'Machine-readable output')
.action((ids: string[], options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => agentAck(deps, { ids }))
);
agent
.command('rm <id>')
.description('Delete any session except this one (refuses your own id)')
+17 -1
View File
@@ -21,9 +21,25 @@ export const MAX_AUTH_SESSIONS = 100;
// Rate Limiting
// ============================================================================
/** Max failed auth attempts per IP before 429 rejection */
/**
* Max failed auth attempts per bucket before 429 rejection. Applies to the
* single-user per-IP bucket, the hook-secret bucket and two multi-user buckets:
* the (username, IP) gate, checked before the scrypt verify, and the
* per-username bucket, checked after it.
*/
export const AUTH_FAILURE_MAX = 10;
/**
* Max failed login attempts per client IP for the multi-user pre-gate, which
* runs before the scrypt verify and so refuses even a correct password once
* reached. It bounds scrypt cost from one source and is wider than
* AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can carry many users'
* failures. Verified guesses against any one account are capped by the
* (username, IP) gate at AUTH_FAILURE_MAX, not by this. Single-user mode does
* not use this.
*/
export const AUTH_IP_FAILURE_MAX = 50;
/** Failed auth attempt tracking window (ms) */
export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
+510
View File
@@ -0,0 +1,510 @@
/**
* @fileoverview Optional host readings for the header stats: GPU load and memory, and the live CPU and GPU clock
* speeds.
*
* Every reader is opt-in per request (the route only calls the ones the browser asks for, and the browser only
* asks when the matching setting is on), answers `undefined` when the host has no such sensor, and never
* throws. Reads are cached for a couple of seconds so several open tabs cost one read.
*
* Sources, with no extra dependency:
* - GPU: `nvidia-smi` (NVIDIA), else the amdgpu sysfs files (`gpu_busy_percent`, `mem_info_vram_*`,
* `pp_dpm_sclk`), else, inside WSL, the Windows performance counters through `typeperf.exe` (any vendor,
* Intel included: load and memory in use, but Windows publishes no GPU clock for them).
* - CPU clock: inside WSL the Windows `Processor Frequency` counter (WSL's own cpuinfo is a fixed number),
* else the mean of `scaling_cur_freq` across cores, else the `cpu MHz` lines of /proc/cpuinfo.
*
* The parsers are pure and exported so the tests need no hardware.
*
* @module host-sensors
*/
import { execFile, spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
export interface GpuReading {
/** Core utilisation, 0 to 100. */
percent: number;
/** Graphics memory in use as a share of what is available, 0 to 100, when that is known. */
memPercent?: number;
memUsedMB?: number;
memTotalMB?: number;
/** Current graphics clock in MHz, when known. */
mhz?: number;
}
export interface HostSensors {
gpu?: GpuReading;
cpuMhz?: number;
}
export interface SensorRequest {
/** GPU load, graphics memory and clock. */
gpu?: boolean;
cpuMhz?: boolean;
}
const CACHE_MS = 2000;
/** A counter sample older than this (a few sampling intervals) is no longer a live reading. */
const FRESH_MS = 10000;
/** After a reader fails, leave it alone this long: nvidia-smi absent or timing out, PowerShell failing. */
const READER_BACKOFF_MS = 60000;
/** The GPU counters stay in the sampler this long after the last GPU read that needed them. */
const GPU_COUNTERS_HOLD_MS = 120000;
const EXEC_TIMEOUT_MS = 1500;
const SYS_DRM = '/sys/class/drm';
const SYS_CPU = '/sys/devices/system/cpu';
const TYPEPERF = '/mnt/c/Windows/System32/typeperf.exe';
const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';
const MB = 1048576;
const clampPercent = (n: number) => Math.min(100, Math.max(0, Math.round(n)));
const finite = (n: number) => (Number.isFinite(n) ? n : undefined);
/** Parse `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr --format=csv,noheader,nounits`. */
export function parseNvidiaSmi(output: string): GpuReading | undefined {
const line = output.split('\n').find((l) => l.trim());
if (!line) return undefined;
const [util, used, total, clock] = line.split(',').map((s) => finite(Number(s.trim())));
if (util === undefined) return undefined;
const reading: GpuReading = { percent: clampPercent(util) };
if (used !== undefined && total !== undefined && total > 0) {
reading.memUsedMB = Math.round(used);
reading.memTotalMB = Math.round(total);
reading.memPercent = clampPercent((used / total) * 100);
}
if (clock !== undefined && clock > 0) reading.mhz = Math.round(clock);
return reading;
}
/** The active line of an amdgpu `pp_dpm_sclk` table (`1: 1800Mhz *`). */
export function parseDpmClock(text: string): number | undefined {
const active = text.split('\n').find((l) => l.trim().endsWith('*'));
const mhz = active ? /(\d+)\s*mhz/i.exec(active)?.[1] : undefined;
return mhz ? Number(mhz) : undefined;
}
/** Mean of per-core `scaling_cur_freq` values (kHz) as MHz. */
export function meanKhzAsMhz(values: number[]): number | undefined {
const good = values.filter((v) => Number.isFinite(v) && v > 0);
if (!good.length) return undefined;
return Math.round(good.reduce((a, b) => a + b, 0) / good.length / 1000);
}
/** Mean of the `cpu MHz : 3412.5` lines of /proc/cpuinfo. */
export function parseCpuInfoMhz(text: string): number | undefined {
const values = [...text.matchAll(/^cpu MHz\s*:\s*([\d.]+)/gim)].map((m) => Number(m[1]));
return meanKhzAsMhz(values.map((v) => v * 1000));
}
export interface WindowsCounters {
gpu?: { percent: number; memUsedMB: number; dedicated: boolean };
cpuMhz?: number;
}
const ENGINE =
/GPU Engine\(.*?_luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+_eng_\d+_engtype_([^)]*)\)\\Utilization Percentage/i;
const ADAPTER_MEMORY = /GPU Adapter Memory\(luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+\)\\(Shared|Dedicated) Usage/i;
const CPU_FREQUENCY = /Processor Information\(_Total\)\\Processor Frequency/i;
/**
* Parse one `typeperf.exe -sc 1 ...` sample: a quoted CSV header of counter paths and one row of values.
* Task Manager's GPU figure is its busiest engine, so per adapter the load is the largest per-engine-type sum
* (3D, copy, video decode...), capped at 100. The adapter that holds the most graphics memory is the real one
* (Windows also lists software adapters that hold a few kilobytes). A `Shared Usage` only means an integrated
* GPU, whose memory is carved out of system RAM.
*/
export function parseTypeperf(output: string): WindowsCounters {
const rows = output
.split(/\r?\n/)
.filter((l) => l.startsWith('"'))
.map((l) => l.slice(1, l.lastIndexOf('"')).split('","'));
if (rows.length < 2) return {};
const [names, values] = rows;
const engines = new Map<string, Map<string, number>>();
const memory = new Map<string, { shared: number; dedicated: number }>();
let cpuMhz: number | undefined;
names.forEach((name, i) => {
const value = finite(Number(values[i]));
if (value === undefined || values[i].trim() === '') return;
const engine = ENGINE.exec(name);
if (engine) {
const byType = engines.get(engine[1].toLowerCase()) ?? new Map<string, number>();
byType.set(engine[2], (byType.get(engine[2]) ?? 0) + value);
engines.set(engine[1].toLowerCase(), byType);
return;
}
const mem = ADAPTER_MEMORY.exec(name);
if (mem) {
const luid = mem[1].toLowerCase();
const entry = memory.get(luid) ?? { shared: 0, dedicated: 0 };
if (mem[2].toLowerCase() === 'shared') entry.shared = value;
else entry.dedicated = value;
memory.set(luid, entry);
return;
}
if (CPU_FREQUENCY.test(name) && value > 0) cpuMhz = Math.round(value);
});
const result: WindowsCounters = {};
if (cpuMhz) result.cpuMhz = cpuMhz;
const luids = new Set([...engines.keys(), ...memory.keys()]);
let best: { luid: string; bytes: number; load: number } | undefined;
for (const luid of luids) {
const m = memory.get(luid);
const bytes = (m?.shared ?? 0) + (m?.dedicated ?? 0);
const load = Math.max(0, ...(engines.get(luid)?.values() ?? []));
if (!best || bytes > best.bytes || (bytes === best.bytes && load > best.load)) best = { luid, bytes, load };
}
if (best && (engines.size > 0 || memory.size > 0)) {
result.gpu = {
percent: clampPercent(best.load),
memUsedMB: Math.round(best.bytes / MB),
dedicated: (memory.get(best.luid)?.dedicated ?? 0) > 0,
};
}
return result;
}
/**
* Run a reader, answering undefined on failure or once `timeout` has passed. execFile's own `timeout` only
* sends the child a signal, and its callback waits for the child to exit: an nvidia-smi stuck on a hung
* driver (uninterruptible sleep) never does, and every stats request would wait on it with CPU and MEM. So
* the deadline here answers by itself; the child is left to the signal.
*/
const run = (cmd: string, args: string[], timeout = EXEC_TIMEOUT_MS) =>
new Promise<string | undefined>((resolve) => {
const deadline = setTimeout(() => resolve(undefined), timeout);
deadline.unref();
execFile(cmd, args, { timeout, windowsHide: true, maxBuffer: 4 * MB }, (err, stdout) => {
clearTimeout(deadline);
resolve(err ? undefined : stdout);
});
});
const readText = (path: string) =>
readFile(path, 'utf8').then(
(t) => t.trim(),
() => undefined
);
const readNumber = async (path: string) => {
const text = await readText(path);
return text === undefined ? undefined : finite(Number(text));
};
/** Linux running under WSL: Windows programs can be started, and the GPU belongs to Windows. */
let wsl: boolean | undefined;
function inWsl(): boolean {
wsl ??=
process.platform === 'linux' &&
(Boolean(process.env.WSL_INTEROP) ||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') ||
existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late'));
return wsl;
}
let windowsRamMB: number | undefined;
let ramProbe: Promise<void> | undefined;
let ramRetryAt = 0;
/**
* Windows' installed RAM in MB once PowerShell has answered, else undefined. The probe (about a second, 8 s
* timeout) is started here but never awaited: the stats route answers CPU and MEM only after the sensors, so
* the GPU reading carries the memory in use alone until the answer lands, and the total from then on. A
* failure is not retried on every GPU poll: it waits READER_BACKOFF_MS first.
*/
function windowsRamMBIfKnown(): number | undefined {
if (windowsRamMB !== undefined || ramProbe || Date.now() < ramRetryAt) return windowsRamMB;
ramProbe = run(
POWERSHELL,
['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'],
8000
).then((out) => {
const bytes = out ? finite(Number(out.trim())) : undefined;
if (bytes && bytes > 0) windowsRamMB = Math.round(bytes / MB);
else ramRetryAt = Date.now() + READER_BACKOFF_MS;
ramProbe = undefined;
});
return undefined;
}
/** What the sampler needs from a child process; `ChildProcess` fits, and tests pass a fake. */
export type SamplerChild = Pick<ChildProcess, 'stdout' | 'kill' | 'once'>;
export interface CounterStreamOptions {
start: (counters: string[]) => SamplerChild;
now?: () => number;
/**
* How long a read waits for a sample when it has none fresh (0, the default, never waits: the read
* answers undefined and the next poll shows the reading). The stats route awaits the sensors before it
* answers CPU and MEM, so a cold sampler must not hold it up.
*/
firstSampleMs?: number;
/** A sampler older than this is replaced, so counters of processes that started since are picked up. */
recycleMs?: number;
/** A sampler nobody has read for this long is stopped. */
idleMs?: number;
/** After the sampler fails to start or exits at once, do not try again for this long. */
backoffMs?: number;
}
/**
* One long-running `typeperf.exe -si 2` instead of a process per read: every `typeperf` sample takes over a
* second (rate counters need two collections), which would keep one running back to back. The newest row is kept
* and handed out; the process is replaced every `recycleMs` (its wildcard counters are expanded once, at
* start) and stopped after `idleMs` without a read, so nothing runs while the settings are off.
*/
export function createCounterStream(options: CounterStreamOptions) {
const now = options.now ?? Date.now;
const firstSampleMs = options.firstSampleMs ?? 0;
const recycleMs = options.recycleMs ?? 30000;
const idleMs = options.idleMs ?? 30000;
const backoffMs = options.backoffMs ?? 60000;
let child: SamplerChild | undefined;
let key = '';
let startedAt = 0;
let lastRead = 0;
let blockedUntil = 0;
let latest: { at: number; value: WindowsCounters } | undefined;
let waiting: Array<() => void> = [];
const stop = () => {
const old = child;
child = undefined;
if (old) {
try {
old.kill();
} catch {
/* already gone */
}
}
};
const begin = (counters: string[]) => {
stop();
const nextKey = counters.join('\n');
// A sample of other counters is no answer; one from an older sampler of the same counters still is.
if (nextKey !== key) latest = undefined;
key = nextKey;
startedAt = now();
// The header row and the partial line belong to this process alone: a late row from a replaced sampler
// must never become the next one's header.
let header: string | undefined;
let buffer = '';
let sampled = false;
let started: SamplerChild;
try {
started = options.start(counters);
} catch {
blockedUntil = now() + backoffMs;
return;
}
child = started;
started.stdout?.on('data', (chunk: Buffer | string) => {
if (child !== started) return;
buffer += chunk.toString();
let at: number;
while ((at = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, at).replace(/\r$/, '');
buffer = buffer.slice(at + 1);
if (!line.startsWith('"')) continue;
if (!header) {
header = line;
continue;
}
sampled = true;
latest = { at: now(), value: parseTypeperf(`${header}\n${line}`) };
const release = waiting;
waiting = [];
release.forEach((fn) => fn());
}
});
const ended = () => {
if (child !== started) return;
child = undefined;
// It ended without a sample (typeperf missing, counters unknown): leave it alone for a while.
if (!sampled) blockedUntil = now() + backoffMs;
const release = waiting;
waiting = [];
release.forEach((fn) => fn());
};
started.once('exit', ended);
started.once('error', ended);
};
return {
/** The newest fresh sample for these counters, or undefined (a cold sampler is not waited for unless `firstSampleMs` says so). */
async read(counters: string[]): Promise<WindowsCounters | undefined> {
lastRead = now();
if (now() < blockedUntil) return undefined;
const wanted = counters.join('\n');
if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters);
if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) {
if (!child) return undefined;
if (firstSampleMs > 0) {
await new Promise<void>((resolve) => {
waiting.push(resolve);
setTimeout(resolve, firstSampleMs).unref();
});
}
}
return latest && now() - latest.at < FRESH_MS ? latest.value : undefined;
},
/** Stop the sampler when nobody has read it lately. */
sweep() {
if (child && now() - lastRead > idleMs) {
stop();
latest = undefined;
}
},
stop,
};
}
const GPU_COUNTERS = [
'\\GPU Engine(*)\\Utilization Percentage',
'\\GPU Adapter Memory(*)\\Shared Usage',
'\\GPU Adapter Memory(*)\\Dedicated Usage',
];
const CPU_COUNTERS = ['\\Processor Information(_Total)\\Processor Frequency'];
/**
* The counters one sampler reads. They do not depend on which reading a browser asked for (those are per
* device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added on a
* machine with no native GPU reader (and dropped again once nothing has needed them for a while). A request then only picks its fields from the sample.
*/
export function windowsCounterList(needGpu: boolean): string[] {
return needGpu ? [...GPU_COUNTERS, ...CPU_COUNTERS] : [...CPU_COUNTERS];
}
export function createWindowsSource(
stream: Pick<ReturnType<typeof createCounterStream>, 'read' | 'stop'>,
now: () => number = Date.now
) {
let gpuUntil = 0;
return {
/**
* A GPU reading is wanted and no native reader answered: sample the GPU counters too, for
* GPU_COUNTERS_HOLD_MS after the last such read. A single failed nvidia-smi therefore costs a sampler
* restart, not a GPU sampler for the life of the server.
*/
needGpuCounters() {
gpuUntil = now() + GPU_COUNTERS_HOLD_MS;
},
read: () => stream.read(windowsCounterList(now() < gpuUntil)),
stop: () => stream.stop(),
};
}
const windowsCounters = createCounterStream({
// Spawn the Windows program directly: if it is missing, the error lands in the stream's back-off.
start: (counters) =>
spawn(TYPEPERF, ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }),
});
const windowsSource = createWindowsSource(windowsCounters);
let sweepTimer: ReturnType<typeof setInterval> | undefined;
/** Stop the background sampler and its idle sweep (the server calls this on shutdown). */
export function stopHostSensors(): void {
if (sweepTimer) clearInterval(sweepTimer);
sweepTimer = undefined;
windowsSource.stop();
}
async function readAmdGpu(): Promise<GpuReading | undefined> {
const cards = (await readdir(SYS_DRM).catch(() => [] as string[])).filter((n) => /^card\d+$/.test(n)).sort();
for (const card of cards) {
const dev = join(SYS_DRM, card, 'device');
const busy = await readNumber(join(dev, 'gpu_busy_percent'));
if (busy === undefined) continue;
const reading: GpuReading = { percent: clampPercent(busy) };
const used = await readNumber(join(dev, 'mem_info_vram_used'));
const total = await readNumber(join(dev, 'mem_info_vram_total'));
if (used !== undefined && total !== undefined && total > 0) {
reading.memUsedMB = Math.round(used / MB);
reading.memTotalMB = Math.round(total / MB);
reading.memPercent = clampPercent((used / total) * 100);
}
const sclk = await readText(join(dev, 'pp_dpm_sclk'));
const mhz = sclk ? parseDpmClock(sclk) : undefined;
if (mhz) reading.mhz = mhz;
return reading;
}
return undefined;
}
/** nvidia-smi is not run again before this time: it failed or timed out, and waits READER_BACKOFF_MS. */
let nvidiaSkipUntil = 0;
/** NVIDIA, then AMD. A host without a working nvidia-smi is not asked again on every poll. */
async function readNativeGpu(): Promise<GpuReading | undefined> {
if (Date.now() >= nvidiaSkipUntil) {
const out = await run('nvidia-smi', [
'--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr',
'--format=csv,noheader,nounits',
]);
const reading = out ? parseNvidiaSmi(out) : undefined;
if (reading) return reading;
nvidiaSkipUntil = Date.now() + READER_BACKOFF_MS;
}
return readAmdGpu();
}
/** A GPU reading from the Windows counters (WSL only): load and memory in use, but no clock. */
function gpuFromWindows(windows: WindowsCounters | undefined): GpuReading | undefined {
if (!windows?.gpu) return undefined;
const { percent, memUsedMB, dedicated } = windows.gpu;
const reading: GpuReading = { percent, memUsedMB };
// An integrated GPU draws its memory from system RAM, and Windows lets it use up to half of it. Until
// Windows has said how much RAM it has, the reading is the memory in use alone (the header shows no ring).
const ram = dedicated ? undefined : windowsRamMBIfKnown();
if (ram) {
reading.memTotalMB = Math.round(ram / 2);
reading.memPercent = clampPercent((memUsedMB / reading.memTotalMB) * 100);
}
return reading;
}
async function readCpuMhz(windows: WindowsCounters | undefined): Promise<number | undefined> {
// Under WSL the Linux side only has a fixed figure, so until Windows answers there is no reading.
if (windows) return windows.cpuMhz;
const cores = (await readdir(SYS_CPU).catch(() => [] as string[])).filter((n) => /^cpu\d+$/.test(n));
const khz = await Promise.all(cores.map((c) => readNumber(join(SYS_CPU, c, 'cpufreq', 'scaling_cur_freq'))));
const fromSysfs = meanKhzAsMhz(khz.filter((v): v is number => v !== undefined));
if (fromSysfs) return fromSysfs;
const info = await readText('/proc/cpuinfo');
return info ? parseCpuInfoMhz(info) : undefined;
}
const cache = new Map<string, { at: number; value: HostSensors }>();
const inFlight = new Map<string, Promise<HostSensors>>();
/** Read only what was asked for. Missing sensors are simply absent from the result. */
export async function readHostSensors(request: SensorRequest): Promise<HostSensors> {
const key = `${request.gpu ? 'g' : ''}${request.cpuMhz ? 'c' : ''}`;
if (!key) return {};
sweepTimer ??= setInterval(() => windowsCounters.sweep(), 10000).unref();
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
const running = inFlight.get(key);
if (running) return running;
const promise = (async () => {
// Native GPU readers first: a machine with a working nvidia-smi never starts the GPU counters.
const nativeGpu = request.gpu ? await readNativeGpu() : undefined;
const wsl = inWsl();
const windowsGpu = wsl && request.gpu && !nativeGpu;
if (windowsGpu) windowsSource.needGpuCounters();
const windows = wsl && (request.cpuMhz || windowsGpu) ? await windowsSource.read() : undefined;
const [gpu, cpuMhz] = await Promise.all([
request.gpu ? (nativeGpu ?? gpuFromWindows(windows)) : undefined,
request.cpuMhz ? readCpuMhz(wsl ? (windows ?? {}) : undefined) : undefined,
]);
const value: HostSensors = {};
if (gpu) value.gpu = gpu;
if (cpuMhz) value.cpuMhz = cpuMhz;
cache.set(key, { at: Date.now(), value });
return value;
})().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
+20 -3
View File
@@ -89,6 +89,19 @@ export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
}
/**
* Remote tmux runs in sshd's non-interactive shell, which may omit Homebrew and
* per-user binaries. Keep every remote tmux operation on the same PATH. Quoting
* the entire assignment preserves spaces in HOME/inherited PATH under dash too.
* CLI commands still use their existing login-shell/configuration handling.
*/
export function withRemoteTmuxPath(command: string): string {
return (
'export PATH="$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; ' +
command
);
}
/**
* The CLI text a location overlay should launch for `mode`, or null when this build has no
* entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare
@@ -228,7 +241,11 @@ export function buildRemoteTmuxCheckCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): string {
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
return [
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(withRemoteTmuxPath('command -v tmux')),
].join(' ');
}
export interface RemoteTmuxCheckResult {
@@ -368,7 +385,7 @@ export function buildRemoteSessionAliveCommand(
): string {
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`;
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' ');
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd))].join(' ');
}
/**
@@ -453,7 +470,7 @@ export function buildRemoteListSessionsCommand(
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
const remoteCmd =
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
parts.push(remoteSshTarget(host), shellescape(withRemoteTmuxPath(remoteCmd)));
return parts.join(' ');
}
+8 -2
View File
@@ -48,7 +48,13 @@ import { exec } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { promisify } from 'node:util';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
import {
buildSshConnectionArgs,
classifyRemoteAliveExit,
remoteSshTarget,
shellescape,
withRemoteTmuxPath,
} from './remote-hosts.js';
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { remoteTmuxLocation } from './tmux-manager.js';
@@ -153,7 +159,7 @@ export function buildRemoteScrollbackCommand(
marker: string
): string {
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
const script = withRemoteTmuxPath(buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker));
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
}
+12
View File
@@ -151,6 +151,7 @@ import {
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
import { resolveConfigModel } from './model-config-resolvers.js';
import { caseSessionSettled, isCasePathLocked, noteCaseSessionStarting } from './case-rename.js';
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
import {
launchModelFor,
@@ -948,6 +949,15 @@ export class Session extends EventEmitter {
}
) {
super();
// A case folder being renamed (src/case-rename.ts) must not gain a session mid-move: its pane would
// start in a directory that is about to have a different path.
if (config.workingDir && isCasePathLocked(config.workingDir)) {
throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`);
}
// And a rename must not start until this session is in the server's map, where it can be listed and
// stopped: until registerSessionWithLayout() (or stop()) settles it, the rename route refuses the move.
// A remote session's workingDir is the remote path, which the route never counts as the folder's.
if (config.workingDir && !config.remote) noteCaseSessionStarting(this, config.workingDir);
this.setMaxListeners(25);
// Default error handler prevents unhandled 'error' events from crashing the process.
@@ -5175,6 +5185,8 @@ export class Session extends EventEmitter {
async stop(killMux: boolean = true): Promise<void> {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// A session stopped before it was registered (a create that failed) no longer holds up a case rename.
caseSessionSettled(this);
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
// without this the last chip it drew would ride along on its row forever.
this._watching = null;
+47 -14
View File
@@ -84,6 +84,7 @@ import {
remoteLoginShellCommand,
remoteSshTarget,
remoteTmuxSessionAlive,
withRemoteTmuxPath,
} from './remote-hosts.js';
import {
buildDockerBaseArgs,
@@ -128,7 +129,7 @@ import {
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
import { restrictTmuxPrefixTable } from './tmux-prefix-table.js';
import { NESTED_TMUX_PREFIX, nestedPrefixTableResetCommands, restrictTmuxPrefixTable } from './tmux-prefix-table.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -479,9 +480,11 @@ export interface PasteTextCommands {
/**
* Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;`
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l -- 'a;'` types `a`, a lone `;`
* is no argument at all); a final `\;` is its escape for a literal `;`. A `;`
* anywhere else in the argument is literal already. Exported for unit testing.
* anywhere else in the argument is literal already. Every `send-keys -l` passes
* `--` before the text, like `set-buffer`, or tmux parses a text that starts with
* `-` as its own options. Exported for unit testing.
*/
export function escapeTmuxTrailingSemicolon(text: string): string {
return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
@@ -1229,7 +1232,9 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0'
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
@@ -1343,7 +1348,7 @@ export function buildRemoteLaunchCommand(options: {
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
'set -s escape-time 0',
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
// to the SMALLEST attached client, so two Codemans at different viewports
@@ -1352,6 +1357,15 @@ export function buildRemoteLaunchCommand(options: {
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
// remote tmux server's other sessions keep their own sizing behavior.
`set -t ${remoteName} window-size latest`,
// The prefix TABLE is server-global (unlike `prefix C-q`, which is per
// session). This socket is dedicated to Codeman's owned sessions, so the
// same reduction as the local socket applies: Ctrl+Q then )/(/s/w/L must
// not switch-client / choose-tree onto a sibling. Bind, then unbind-all,
// then bind again: a user config that left the prefix table empty makes
// `unbind-key -a -T prefix` fail and abort the rest of this chain.
// BEFORE remain-on-exit, which stays last because an old remote tmux
// rejects `failed` and aborts the rest of the chain.
...nestedPrefixTableResetCommands(),
// #210: keep a CRASHED pane so the failure is still on screen. Without this,
// tmux destroys the pane -> window -> session (and, being the only session,
// the whole remote server) the instant the pane command exits, which tears the
@@ -1368,7 +1382,8 @@ export function buildRemoteLaunchCommand(options: {
// LAST in the chain on purpose: tmux aborts the remaining commands of a `\;`
// sequence once one errors (also verified), and `failed` needs tmux >= 3.2 on
// the REMOTE host. Trailing, a rejection costs only this option; leading, it
// would silently drop status/mouse/prefix/escape-time/window-size with it.
// would silently drop status/mouse/prefix/escape-time/window-size and the
// prefix-table reduction with it.
`set -t ${remoteName} remain-on-exit failed`,
].join(' \\; ');
@@ -1381,7 +1396,14 @@ export function buildRemoteLaunchCommand(options: {
// `ssh -o BatchMode=yes` (preserving the historical token order), then the rest
// of the connection args, then the target and the quoted tmux invocation.
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
const sshParts = [
ssh,
batchMode,
'-t',
...connectionArgs,
remoteSshTarget(remote),
shellescape(withRemoteTmuxPath(tmuxInvocation)),
];
return sshParts.join(' ');
}
@@ -1398,7 +1420,7 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
const remoteName = remoteTmuxSessionName(sessionId);
const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`;
const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(withRemoteTmuxPath(killCmd))].join(' ');
}
// ========== Docker cases (COD-Docker) ==========
@@ -1540,8 +1562,12 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
`set -t ${dkrName} status off`,
`set -t ${dkrName} mouse off`,
`set -t ${dkrName} prefix C-q`,
`set -t ${dkrName} prefix ${NESTED_TMUX_PREFIX}`,
'set -s escape-time 0',
// Same reduction as the owned-remote chain: the in-container server is
// Codeman's (`-L codeman-docker`), and its stock prefix table would move
// the inner client to a sibling session on Ctrl+Q then )/(/s/w/L.
...nestedPrefixTableResetCommands(),
].join(' \\; ');
const execEnvFlags: string[] = [];
@@ -1777,7 +1803,14 @@ export function resolveDockerLaunchOptions(
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
const sshParts = [
ssh,
batchMode,
'-t',
...connectionArgs,
remoteSshTarget(remote),
shellescape(withRemoteTmuxPath(tmuxInvocation)),
];
return sshParts.join(' ');
}
@@ -3997,7 +4030,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await run(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else {
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
@@ -4014,7 +4047,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else if (textPart) {
// Text only, no Enter
await run(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else if (hasCarriageReturn) {
// Enter only
@@ -4168,7 +4201,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (textPart && hasCarriageReturn) {
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
`${tmux} send-keys -t ${shellescape(target)} -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
@@ -4180,7 +4213,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
} else if (textPart) {
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
`${tmux} send-keys -t ${shellescape(target)} -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
+42 -5
View File
@@ -27,11 +27,18 @@
* per tmux server in practice.
*
* That tolerance is server-wide like every key table: while such an alias is
* bound, a browser tab's client can detach itself with it too. The same goes
* for the `root` keys an attach claims, including Alt+1..9, which run
* `switch-client`: a tab that receives Esc then a digit within tmux's
* `escape-time` is moved onto another session for as long as those bindings
* exist. Leaving `root` alone keeps the attach working; it does not close that.
* bound, a browser tab's client can detach itself with it too. F1 in `root`
* is the same kind of way out. Alt+1..9 are not: `codeman tui` binds those
* through `if-shell -F` so `switch-client` runs only for the TUI's own client
* (see `bindSwitchKey` in src/tui/tui-client.ts). This file still does not
* touch `root`.
*
* Docker cases and owned remote sessions run a second tmux (`-L codeman-docker`,
* `-L codeman-remote`) with `prefix C-q`. That server is not this socket, so
* the reset above never sees it, and its stock prefix table still switches
* the inner client. `nestedPrefixTableResetCommands` is the same reduction,
* appended to those create chains. A non-owned remote attach joins another
* Codeman's `-L codeman` and must not run it.
*
* @module tmux-prefix-table
*/
@@ -43,6 +50,36 @@ import { shellescape } from './remote-hosts.js';
/** tmux's stock prefix, used when `show-options` cannot be read. */
const DEFAULT_PREFIX = 'C-b';
/**
* Prefix on the dedicated tmux inside a Docker case and an owned remote
* session. Codeman's own socket keeps the user's prefix; these nested servers
* always use C-q so it does not collide with the outer prefix.
*/
export const NESTED_TMUX_PREFIX = 'C-q';
/**
* Commands that reduce a nested tmux's prefix table to `C-q send-prefix`.
*
* Joined with `\;` onto the `new-session` that creates the server
* (`buildRemoteLaunchCommand`, `buildDockerLaunchCommand`). No `tmux` binary
* and no `-g`: the table is server-global, which is what we want on these
* dedicated sockets and nowhere else.
*
* Those servers load the user's tmux config (no `-f /dev/null`). A config that
* leaves the prefix table empty makes `unbind-key -a -T prefix` fail with
* "table prefix doesn't exist", and tmux then drops every later command in the
* `\;` chain, including the bind and `remain-on-exit failed` (#210). The same
* empty table is what #669 handled on the local socket. Binding `send-prefix`
* first creates the table so the unbind can run; the unbind clears stock
* bindings (and that first bind); the second bind puts `send-prefix` back.
* Put them BEFORE `remain-on-exit failed` so a remote tmux that rejects
* `failed` does not drop the reduction.
*/
export function nestedPrefixTableResetCommands(): readonly [string, string, string] {
const sendPrefix = `bind-key -T prefix ${NESTED_TMUX_PREFIX} send-prefix`;
return [sendPrefix, 'unbind-key -a -T prefix', sendPrefix];
}
/** One `list-keys -T prefix` line: key and command. */
const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/;
+43 -8
View File
@@ -56,6 +56,8 @@ import { composerScroll, composerStep, composerText, createComposer, type TuiCom
import { formatAwayDigest } from './tui-digest.js';
import {
ATTACH_BANNER_MARKER,
isTuiClientTty,
readControllingTty,
TuiClient,
type TuiSessionOptions,
type TuiWindowSizing,
@@ -550,7 +552,14 @@ export async function beginAttachHandoff(
label: string,
tabs: readonly TuiAttachTab[] = [],
cols?: number,
rows?: number
rows?: number,
/**
* Tty the attach client will inherit. Omit to read this process's terminal.
* `null` means there is no usable tty: switch keys are not bound (an unscoped
* `switch-client` would move every client on the socket, including a browser
* tab). Tests pass a tty so they do not depend on stdin.
*/
clientTty?: string | null
): Promise<TuiAttachHandoff> {
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
// Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes.
@@ -584,13 +593,29 @@ export async function beginAttachHandoff(
// usable rather than decorative. Only the first nine, only sessions that
// really have a pane, and only keys tmux reports as free — the same rule the
// way-out key follows, so a binding of the user's own is never shadowed.
//
// The key table is server-global. The command is guarded to this terminal's
// client (see bindSwitchKey): without a tty we bind nothing, and a leaked
// unscoped `switch-client` from an older TUI is removed so it cannot keep
// moving browser tabs. When we do claim the keys, drop whatever previous
// attach left on M-1..M-9 first — including keys this strip will not reuse.
const switchKeys: string[] = [];
for (const tab of tabs.slice(0, 9)) {
if (!tab.muxName) continue;
const key = `M-${tab.index}`;
const bound = await client.readPrefixBinding(key, 'root');
if (bound !== null && !bound.startsWith('switch-client')) continue;
if (await client.bindSwitchKey(key, tab.muxName)) switchKeys.push(key);
const tty = clientTty === undefined ? readControllingTty() : clientTty || null;
const switchTabs = tabs.slice(0, 9).filter((tab) => tab.index >= 1 && tab.index <= 9 && tab.muxName);
const guarded = Boolean(tty && isTuiClientTty(tty));
// The keys are bound here but stay inert until the attach hook records this
// client's pid, and that hook is armed at the very end (see below).
if (guarded && switchTabs.length > 0) {
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`);
for (const tab of switchTabs) {
const key = `M-${tab.index}`;
const bound = await client.readPrefixBinding(key, 'root');
// Takeover just cleared ours. Anything still here belongs to the user.
if (bound !== null) continue;
if (await client.bindSwitchKey(key, tab.muxName as string, tty as string)) switchKeys.push(key);
}
} else {
for (let index = 1; index <= 9; index++) await client.unbindSwitchKey(`M-${index}`, '');
}
const bannerFor = (activeMux: string, ownLabel: string): Record<string, string> =>
buildAttachBanner({
@@ -635,10 +660,20 @@ export async function beginAttachHandoff(
if (cols && rows && rows > 1) await client.presizeWindow(target, cols, rows - 1);
if (target === muxName) await client.followAttachingClient(target);
}
// ⚠️ Armed LAST, as the final tmux call before the caller's `spawnSync`. The
// hook is one-shot for this tty, so a TUI killed between arming and attaching
// leaves it armed, and Linux hands the lowest free pty to the next opener: a
// browser tab's attach client could land on this tty and have its pid
// recorded, which would make these keys move that tab. Arming after the
// dressing loop above shrinks that window to the attach's own startup. A
// failed arm leaves the keys inert (the closed direction), and `restore()`
// still unbinds them.
if (switchKeys.length > 0) await client.armTuiSwitchGuard(tty as string);
return {
chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined),
async restore(): Promise<void> {
for (const key of switchKeys) await client.unbindSwitchKey(key);
for (const key of switchKeys) await client.unbindSwitchKey(key, tty ?? '');
if (tty && isTuiClientTty(tty)) await client.disarmTuiSwitchGuard(tty);
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
if (claimed && alias) await client.unbindDetachKey(alias);
// Options first, then the size: dropping the status bar gives its row
+201 -26
View File
@@ -41,7 +41,7 @@
* @module tui/tui-client
*/
import { execFile as execFileCb } from 'node:child_process';
import { execFile as execFileCb, execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
@@ -445,6 +445,91 @@ export function parsePrefixBinding(stdout: string, key: string, table: TuiKeyTab
return null;
}
/**
* User option holding the tmux client pid of the TUI attach that owns M-1..M-9.
* Set by `TUI_SWITCH_ATTACH_HOOK` when that client attaches, read by the
* `if-shell -F` guard on those keys. A dead pid matches no live client.
*/
export const TUI_SWITCH_CLIENT_PID = '@codeman-tui-pid';
/**
* Indexed so it does not replace a user's own `client-attached` hook. High
* index to stay clear of `[0]`. One-shot: the hook unsets itself after it
* records the pid, so a later client that reuses the pty does not re-arm it.
*/
export const TUI_SWITCH_ATTACH_HOOK = 'client-attached[90]';
/** The keys `bindSwitchKey` binds. The key is interpolated into the binding's `send-keys` else command. */
const TUI_SWITCH_KEY = /^M-[1-9]$/;
/** Linux `/dev/pts/N` and macOS `/dev/ttysNNN`. Anything else is not a client tty we will interpolate into a format. */
const TUI_CLIENT_TTY = /^\/dev\/(?:pts\/[0-9]+|ttys[0-9]+)$/;
export function isTuiClientTty(value: string): boolean {
return TUI_CLIENT_TTY.test(value);
}
/**
* The terminal this process is attached to, when it is a safe tmux client tty.
* `codeman tui` inherits that tty into `tmux attach-session`, which is how the
* guard knows its own client before the client exists. A pipe (tests, no TTY)
* is null: callers then must not bind `switch-client` for every client.
*/
export function readControllingTty(): string | null {
try {
const out = execFileSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'ignore'] }).trim();
return isTuiClientTty(out) ? out : null;
} catch {
return null;
}
}
/**
* `if-shell -F` guard for a TUI switch key. True only for the client whose tty
* is `tty` AND whose pid is the one the attach hook stored. Evaluated when the
* key is pressed, against the client that pressed it.
*/
export function tuiSwitchGuard(tty: string): string {
return `#{&&:#{==:#{client_tty},${tty}},#{==:#{client_pid},#{${TUI_SWITCH_CLIENT_PID}}}}`;
}
/**
* `set-hook` command for `TUI_SWITCH_ATTACH_HOOK`. `\#{client_pid}` is
* defensive. On tmux 3.4 both this form and a bare `#{client_pid}` are stored
* verbatim and both record the attaching client's pid. The escape stays so a
* tmux that expands formats while the hook is stored (no client exists yet)
* cannot save an empty pid.
*/
export function tuiSwitchAttachHook(tty: string): string {
return (
`if-shell -F "#{==:#{client_tty},${tty}}" ` +
`"set -Fg ${TUI_SWITCH_CLIENT_PID} \\"\\#{client_pid}\\"; set-hook -gu ${TUI_SWITCH_ATTACH_HOOK}"`
);
}
const LEGACY_TUI_SWITCH = /^switch-client -t (\S+?)(?: \\; set-window-option -t \1 window-size latest)?$/;
/**
* A root binding this TUI installed: the guarded `if-shell` form, or the
* pre-guard `switch-client -t <mux>` form a killed TUI can leave behind.
* A user's own `switch-client -n` or a target that is not a Codeman session
* is not ours.
*/
export function isTuiSwitchBinding(command: string): boolean {
const text = command.trim();
if (text.includes(TUI_SWITCH_CLIENT_PID) && text.includes('#{client_tty}') && text.includes('switch-client -t ')) {
return true;
}
const legacy = LEGACY_TUI_SWITCH.exec(text);
return legacy !== null && MUX_NAME_PATTERN.test(legacy[1] ?? '');
}
/** The tty embedded in a guarded switch binding, or null for a legacy one. */
export function tuiSwitchBindingTty(command: string): string | null {
const match = /#\{==:#\{client_tty\},(\/dev\/(?:pts\/[0-9]+|ttys[0-9]+))\}/.exec(command);
return match?.[1] ?? null;
}
/**
* The phrase the attach status bar carries. It doubles as the marker that tells
* OUR bar apart from a user's own when sweeping one that leaked, so
@@ -959,21 +1044,33 @@ export class TuiClient {
* `switch-client` rather than detach-then-attach: it keeps the terminal, so
* the move is instant and the TUI stays blocked in its `spawnSync` exactly as
* before, still holding the restore it owes.
*
* The binding is NOT server-wide in effect. Key tables are, so the key is
* bound for every client, but the command is `if-shell -F` and runs only
* when the client that pressed it is this TUI (`clientTty` plus the pid the
* attach hook stored). A browser tab's Esc-then-digit, which tmux reads as
* the same `M-<digit>` inside `escape-time`, does not match. An unsafe tty
* refuses the bind rather than falling back to an unscoped `switch-client`.
*
* A client that fails the guard gets the key back: the if-shell else
* argument is `send-keys <key>`, which writes it into that client's own pane
* exactly as an unbound key would arrive. Without it the root binding ate
* the key, so a browser tab's vim `<Esc>2j` lost its Esc and its digit for
* the whole attach, and for good after a TUI killed before `restore()`. The
* key is interpolated into that command string, so only `M-1`..`M-9` are
* accepted.
*
* The two commands live in the if-shell argument, where `;` is a separator
* inside that string. A bare `;` as its own argv element would end the
* `bind-key` and run `window-size latest` immediately, which is the bug the
* old `\;` argument existed to avoid. The second command is `window-size
* latest`: the window is already the right size on arrival (see
* presizeWindow), so it changes nothing then, and a resize while sitting in
* that session follows this terminal.
*/
async bindSwitchKey(key: string, target: string): Promise<boolean> {
if (!MUX_NAME_PATTERN.test(target)) return false;
async bindSwitchKey(key: string, target: string, clientTty: string): Promise<boolean> {
if (!TUI_SWITCH_KEY.test(key) || !MUX_NAME_PATTERN.test(target) || !isTuiClientTty(clientTty)) return false;
try {
// Two commands: go there, then let that window follow this terminal again.
// It is already the right size (see presizeWindow), so `latest` changes
// nothing on arrival and costs no repaint — it matters only if the
// terminal is resized while sitting in that session.
//
// ⚠️ The separator MUST be an escaped `\;`. A bare `;` is a command
// separator to tmux's own parser, so it ends the `bind-key` and RUNS
// what follows immediately: the binding kept only `switch-client`, and
// every session got `window-size latest` executed on it at attach time.
// That is what left sessions on `latest` after a detach, since the
// snapshot was then taken from already-corrupted state.
await this.exec('tmux', [
'-L',
this.socket,
@@ -981,15 +1078,11 @@ export class TuiClient {
'-T',
'root',
key,
'switch-client',
'-t',
target,
'\\;',
'set-window-option',
'-t',
target,
'window-size',
'latest',
'if-shell',
'-F',
tuiSwitchGuard(clientTty),
`switch-client -t ${target} ; set-window-option -t ${target} window-size latest`,
`send-keys ${key}`,
]);
return true;
} catch {
@@ -997,10 +1090,92 @@ export class TuiClient {
}
}
/** Give back a switch key, but only while it still points at a session. */
async unbindSwitchKey(key: string): Promise<void> {
/**
* Record this TUI's client pid when it attaches, then drop the hook so a
* later client on a reused pty does not take the binding over. Best effort:
* a failure means Alt+1..9 never switch (they reach the pane as Esc then
* the digit), which is the closed direction.
*/
async armTuiSwitchGuard(clientTty: string): Promise<boolean> {
if (!isTuiClientTty(clientTty)) return false;
try {
await this.exec('tmux', [
'-L',
this.socket,
'set-hook',
'-g',
TUI_SWITCH_ATTACH_HOOK,
tuiSwitchAttachHook(clientTty),
]);
return true;
} catch {
return false;
}
}
/**
* Undo `armTuiSwitchGuard` for this tty. The hook is removed only while it
* still names `clientTty` (a second TUI may have replaced it). The pid option
* is removed only when no live client still has it, so a second attach that
* already recorded its own pid is left alone.
*/
async disarmTuiSwitchGuard(clientTty: string): Promise<void> {
if (!isTuiClientTty(clientTty)) return;
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-hooks', '-g']);
const hook = stdout.split('\n').find((line) => line.startsWith(`${TUI_SWITCH_ATTACH_HOOK} `));
// The closed form, not the bare tty: `/dev/pts/4` is a substring of a
// hook armed for `/dev/pts/41`, and removing that one would leave the
// second TUI's Alt keys dead for its whole attach.
if (hook?.includes(`#{client_tty},${clientTty}}`)) {
await this.exec('tmux', ['-L', this.socket, 'set-hook', '-gu', TUI_SWITCH_ATTACH_HOOK]);
}
} catch {
/* no server, or the hook is already gone */
}
let pid = '';
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-options', '-gv', TUI_SWITCH_CLIENT_PID]);
pid = stdout.trim();
} catch {
return;
}
if (!pid) return;
let live = '';
try {
({ stdout: live } = await this.exec('tmux', ['-L', this.socket, 'list-clients', '-F', '#{client_pid}']));
} catch {
live = '';
}
const pids = new Set(
live
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
);
if (pids.has(pid)) return;
try {
await this.exec('tmux', ['-L', this.socket, 'set-option', '-gu', TUI_SWITCH_CLIENT_PID]);
} catch {
/* already unset */
}
}
/**
* Give back a switch key, but only while it is still one of ours.
*
* `ownerTty` set: keep a binding that names a different tty, so this attach's
* restore does not take a second TUI's keys. `ownerTty` omitted: any of our
* bindings on the key goes (the attaching TUI is taking the table over).
* Pass `''` to drop only a legacy unscoped binding.
*/
async unbindSwitchKey(key: string, ownerTty?: string): Promise<void> {
const bound = await this.readPrefixBinding(key, 'root');
if (!bound || !bound.startsWith('switch-client')) return;
if (!bound || !isTuiSwitchBinding(bound)) return;
if (ownerTty !== undefined) {
const boundTty = tuiSwitchBindingTty(bound);
if (boundTty !== null && boundTty !== ownerTty) return;
}
try {
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'root', key]);
} catch {
+3 -3
View File
@@ -36,9 +36,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**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)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)
@@ -86,7 +86,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. ⚠️ The watchdog cannot see an attempt that never OPENS (no response headers: no `open`, no `error`, never `connected`), so each attempt is bounded by `SSE_CONNECT_TIMEOUT_MS` (15s, `CodemanSseStale.CONNECT_TIMEOUT_MS`) and then retried through `_handleSseConnectionFailure()`, the same backoff `onerror` uses. Its timer is cleared at the top of `connectSSE()` BEFORE the offline early return, on `open`, and in `_handleSseConnectionFailure()`; the timer and the handler both ignore an `EventSource` that has been replaced. The window `offline` handler leaves it running on purpose (`navigator.onLine` can report offline on a working network). Tests: `test/sse-connect-timeout.test.ts`. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
+138
View File
@@ -0,0 +1,138 @@
/**
* @fileoverview Opt-in snapshot of the agent inbox, so pending mail between agents
* survives a server restart (the agents themselves keep running in tmux across it).
*
* Off by default: an install that never turns on `agentInboxPersistEnabled` writes
* nothing new to disk. The rules (discussion #558):
* - Off means no file. Switching it off deletes the snapshot; the in-memory inboxes
* stay as they are.
* - Its own file under `dataPath()`, written 0600 through tmp + rename like
* `intents.json`, since messages carry the same kind of text prompts do. It is never
* read by `/api/search` and `sensitive-path.ts` keeps the file viewer off it.
* - Writes are debounced (a burst of posts is one write) and flushed on shutdown.
* - Boot pruning is NOT done here: the server calls `agentInbox.pruneExcept()` only
* after the session restore succeeded, so a failed restore leaves the mail alone.
*
* @module web/agent-inbox-persistence
*/
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../config/instance.js';
import { agentInbox, type AgentInbox } from './agent-inbox.js';
/** A burst of posts (a coordinator fanning out to five workers) becomes one write. */
const WRITE_DEBOUNCE_MS = 250;
export class AgentInboxPersistence {
private enabled = false;
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly inbox: AgentInbox = agentInbox,
/** Resolved per call: test HOMEs change between files. */
private readonly filePath: () => string = () => dataPath('agent-inbox.json'),
private readonly debounceMs: number = WRITE_DEBOUNCE_MS
) {
this.inbox.onChange = () => this.schedule();
}
get isEnabled(): boolean {
return this.enabled;
}
/**
* Boot: apply the setting and, when it is on, load the snapshot into the store.
* Returns how many messages were restored. A missing or unreadable file is an
* empty inbox, never a failed boot.
*/
load(enabled: boolean): number {
this.enabled = enabled;
if (!enabled) {
// A file left from an earlier "on" is removed, so off really means no file.
this.removeFile();
return 0;
}
const path = this.filePath();
if (!existsSync(path)) return 0;
try {
return this.inbox.restore(JSON.parse(readFileSync(path, 'utf-8')));
} catch (err) {
console.warn(`[AgentInbox] Failed to load ${path}, starting empty:`, err);
return 0;
}
}
/**
* The setting changed. On: write the current inboxes now. Off: drop any pending
* write and delete the snapshot; memory is untouched.
*/
setEnabled(enabled: boolean): void {
if (enabled === this.enabled) return;
this.enabled = enabled;
if (enabled) {
this.writeNow();
} else {
this.cancel();
this.removeFile();
}
}
/** Shutdown: write a pending change now instead of losing it with the timer. */
flush(): void {
if (!this.timer) return;
this.cancel();
this.writeNow();
}
/** Test hook: drop a pending write and detach from the store. */
dispose(): void {
this.cancel();
if (this.inbox.onChange) this.inbox.onChange = undefined;
}
private schedule(): void {
if (!this.enabled || this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
this.writeNow();
}, this.debounceMs);
// A pending write must not hold the process open; shutdown flushes it explicitly.
this.timer.unref();
}
private cancel(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
private writeNow(): void {
if (!this.enabled) return;
const path = this.filePath();
const tmpPath = `${path}.tmp`;
try {
mkdirSync(dirname(path), { recursive: true });
// 0600: agent messages can carry anything a prompt can, secrets included.
// `mode` applies only when the file is created, and the rename makes every
// write a fresh file, so the final file is always 0600.
rmSync(tmpPath, { force: true });
writeFileSync(tmpPath, JSON.stringify(this.inbox.snapshot()), { mode: 0o600 });
renameSync(tmpPath, path);
} catch (err) {
console.warn(`[AgentInbox] Failed to persist ${path}:`, err);
}
}
private removeFile(): void {
const path = this.filePath();
try {
rmSync(path, { force: true });
rmSync(`${path}.tmp`, { force: true });
} catch (err) {
console.warn(`[AgentInbox] Failed to remove ${path}:`, err);
}
}
}
/** Process-wide instance wired to `agentInbox`; the server and the settings route use it. */
export const agentInboxPersistence = new AgentInboxPersistence();
+307
View File
@@ -0,0 +1,307 @@
/**
* @fileoverview Agent inbox: a per-session mailbox other agents (or the user) post to,
* and the receiving agent reads when it chooses to.
*
* The other agent-to-agent channel, `POST /api/sessions/:id/input`, types into the
* receiver's composer. That is right for a prompt and wrong for a message: every
* delivery starts or interrupts a turn, the receiver cannot defer it, and it is one
* line only. The inbox stores instead of typing. Nothing here ever reaches a pane.
*
* Invariants (each pinned in `test/agent-inbox.test.ts`):
* - **Read until ack.** A read hands out every pending message and removes nothing.
* A message goes only when the receiver acknowledges its id. "Read and dropped"
* and "done" must not look the same to the sender (discussion #558, the 09-23 case).
* - **Seen is the receiver's alone.** `read(…, { receiver: true })` marks what it
* handed out as seen; any other read is a peek and changes nothing. The route
* decides who the receiver is; this module only takes the flag.
* - **Bounded, and full refuses.** `MAX_MESSAGES_PER_INBOX`, `MAX_MESSAGE_BYTES` and
* `MAX_INBOX_BYTES`. A post that would cross any of them is refused so the sender
* learns. The oldest message is never dropped to make room.
* - **TTL from the message's own timestamp** (`MESSAGE_TTL_MS`). A restore keeps
* `createdAt`, so a restart does not restart the clock. Expiry is lazy: every
* accessor sweeps the inbox it touches, `summary()` and `snapshot()` sweep all.
* - **No IO, no `Session` import.** Persistence (`agent-inbox-persistence.ts`) hooks
* `onChange`; long-poll waiters live in `session-wait-registry.ts`.
*
* @consumedby web/routes/inbox-routes, web/agent-inbox-persistence, web/server
* @module web/agent-inbox
*/
import { randomUUID } from 'node:crypto';
// ─── Bounds ──────────────────────────────────────────────────────────────────
/** Messages one inbox holds before posts are refused. */
export const MAX_MESSAGES_PER_INBOX = 200;
/** UTF-8 bytes per message. A bigger payload belongs in the workspace; post its path. */
export const MAX_MESSAGE_BYTES = 16_384;
/** UTF-8 bytes of message text one inbox holds before posts are refused. */
export const MAX_INBOX_BYTES = 512 * 1024;
/** A message older than this is gone, read or not (same span as the approvals inbox). */
export const MESSAGE_TTL_MS = 12 * 60 * 60 * 1000;
// ─── Types ───────────────────────────────────────────────────────────────────
/** A message as it is stored (and persisted). */
interface StoredMessage {
id: string;
/** The posting session's full id, or null when no session could be resolved (the user). */
from: string | null;
text: string;
/** Epoch ms. The TTL counts from here. */
createdAt: number;
/** Epoch ms of the first receiver read that handed this message out; null while unseen. */
seenAt: number | null;
}
/** A message as a read hands it out. */
export interface InboxMessage {
id: string;
from: string | null;
text: string;
createdAt: number;
/** True when a receiver read had handed this message out BEFORE this read. */
seen: boolean;
}
export interface InboxReadResult {
/** Every pending message, oldest first: unseen and seen-but-unacked alike. */
messages: InboxMessage[];
pending: number;
/** Unseen after this read (0 after a receiver read; unchanged by a peek). */
unseen: number;
}
export type PostResult =
| { ok: true; message: InboxMessage; pending: number; unseen: number }
| { ok: false; reason: 'empty' | 'message-too-large' | 'inbox-full-count' | 'inbox-full-bytes' };
/** What `summary()` reports for one session. Counts and times only, never text. */
export interface InboxCounts {
pending: number;
unseen: number;
/** `createdAt` of the oldest unseen message, null when nothing is unseen. */
oldestUnseenAt: number | null;
}
/** On-disk shape. `version` lets a later format refuse an older file instead of misreading it. */
export interface InboxSnapshot {
version: 1;
inboxes: Record<string, StoredMessage[]>;
}
// ─── Store ───────────────────────────────────────────────────────────────────
export class AgentInbox {
private readonly inboxes = new Map<string, StoredMessage[]>();
/** Any mutation (post, seen, ack, expiry, drop, prune, restore excluded). Persistence hooks this. */
onChange: (() => void) | undefined;
constructor(private readonly now: () => number = Date.now) {}
/**
* Store a message for `sessionId`. `from` is the RESOLVED sender (the route's job),
* never a client-supplied label.
*/
post(sessionId: string, from: string | null, text: string): PostResult {
if (text.length === 0) return { ok: false, reason: 'empty' };
const bytes = Buffer.byteLength(text, 'utf8');
if (bytes > MAX_MESSAGE_BYTES) return { ok: false, reason: 'message-too-large' };
const queue = this.live(sessionId);
if (queue.length >= MAX_MESSAGES_PER_INBOX) return { ok: false, reason: 'inbox-full-count' };
if (inboxBytes(queue) + bytes > MAX_INBOX_BYTES) return { ok: false, reason: 'inbox-full-bytes' };
const stored: StoredMessage = { id: randomUUID(), from, text, createdAt: this.now(), seenAt: null };
queue.push(stored);
this.inboxes.set(sessionId, queue);
this.onChange?.();
return { ok: true, message: view(stored), pending: queue.length, unseen: countUnseen(queue) };
}
/**
* Every pending message, oldest first. Removes nothing. With `receiver`, the
* messages handed out are marked seen (each keeps its first `seenAt`); without it
* this is a peek and changes nothing at all.
*/
read(sessionId: string, options: { receiver: boolean }): InboxReadResult {
const queue = this.live(sessionId);
const messages = queue.map(view);
if (options.receiver) {
let marked = false;
const at = this.now();
for (const m of queue) {
if (m.seenAt !== null) continue;
m.seenAt = at;
marked = true;
}
if (marked) this.onChange?.();
}
return { messages, pending: queue.length, unseen: countUnseen(queue) };
}
/** Messages no receiver read has handed out yet. A `?wait=` read parks while this is 0. */
unseenCount(sessionId: string): number {
return countUnseen(this.live(sessionId));
}
pendingCount(sessionId: string): number {
return this.live(sessionId).length;
}
/**
* Remove the given messages. Idempotent: an id that is unknown or already gone is
* not an error, so a retry after a lost response succeeds. Returns how many went.
*/
ack(sessionId: string, ids: readonly string[]): number {
const queue = this.live(sessionId);
if (queue.length === 0 || ids.length === 0) return 0;
const drop = new Set(ids);
const kept = queue.filter((m) => !drop.has(m.id));
const removed = queue.length - kept.length;
if (removed === 0) return 0;
this.store(sessionId, kept);
this.onChange?.();
return removed;
}
/** Per-session counts for every inbox that holds anything. Never blocks. */
summary(): Map<string, InboxCounts> {
const out = new Map<string, InboxCounts>();
for (const sessionId of [...this.inboxes.keys()]) {
const queue = this.live(sessionId);
if (queue.length === 0) continue;
const unseen = queue.filter((m) => m.seenAt === null);
out.set(sessionId, {
pending: queue.length,
unseen: unseen.length,
oldestUnseenAt: unseen.length > 0 ? unseen[0].createdAt : null,
});
}
return out;
}
/** The session was DELETED: its mail goes with it. Not called on detach or shutdown. */
drop(sessionId: string): void {
if (this.inboxes.delete(sessionId)) this.onChange?.();
}
/**
* Boot-time sweep, run only after the session restore succeeded: discard inboxes
* whose session did not come back. Every route needs a live session, so an orphan
* could otherwise never be removed. Returns how many inboxes were dropped.
*/
pruneExcept(keep: Iterable<string>): number {
const alive = new Set(keep);
let dropped = 0;
for (const sessionId of [...this.inboxes.keys()]) {
if (alive.has(sessionId)) continue;
this.inboxes.delete(sessionId);
dropped++;
}
if (dropped > 0) this.onChange?.();
return dropped;
}
/** Whole-store snapshot for persistence, expired messages already swept. */
snapshot(): InboxSnapshot {
const inboxes: Record<string, StoredMessage[]> = {};
for (const sessionId of [...this.inboxes.keys()]) {
const queue = this.live(sessionId);
if (queue.length > 0) inboxes[sessionId] = queue.map((m) => ({ ...m }));
}
return { version: 1, inboxes };
}
/**
* Replace the store from a snapshot (server boot). Malformed input is skipped entry
* by entry rather than thrown: a corrupt file must not stop the server. Expired
* messages are not restored, and the caps hold for what is. Does not fire
* `onChange`: writing back what was just read would be a no-op at best.
*/
restore(snapshot: unknown): number {
const data = snapshot as Partial<InboxSnapshot> | null;
this.inboxes.clear();
if (!data || data.version !== 1 || typeof data.inboxes !== 'object' || data.inboxes === null) return 0;
const cutoff = this.now() - MESSAGE_TTL_MS;
let restored = 0;
for (const [sessionId, raw] of Object.entries(data.inboxes)) {
if (!Array.isArray(raw)) continue;
const queue: StoredMessage[] = [];
let bytes = 0;
// Oldest first whatever order the file has, so the caps keep the oldest mail,
// which is the rule for posts too.
const entries = raw
.filter((entry): entry is StoredMessage => isStoredMessage(entry) && entry.createdAt > cutoff)
.sort((a, b) => a.createdAt - b.createdAt);
for (const entry of entries) {
const size = Buffer.byteLength(entry.text, 'utf8');
if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES) break;
queue.push({ ...entry });
bytes += size;
}
if (queue.length === 0) continue;
this.inboxes.set(sessionId, queue);
restored += queue.length;
}
return restored;
}
/** Test hook: back to an empty store with no listener. */
resetForTests(): void {
this.inboxes.clear();
this.onChange = undefined;
}
/** The inbox with expired messages swept out (fires `onChange` when it swept any). */
private live(sessionId: string): StoredMessage[] {
const queue = this.inboxes.get(sessionId);
if (!queue) return [];
const cutoff = this.now() - MESSAGE_TTL_MS;
// Every message, not only queue[0]: a clock stepping back can put an older
// createdAt behind a newer one. At most 200 entries, so the scan is cheap.
if (!queue.some((m) => m.createdAt <= cutoff)) return queue;
const kept = queue.filter((m) => m.createdAt > cutoff);
this.store(sessionId, kept);
this.onChange?.();
return kept;
}
private store(sessionId: string, queue: StoredMessage[]): void {
if (queue.length === 0) this.inboxes.delete(sessionId);
else this.inboxes.set(sessionId, queue);
}
}
function view(m: StoredMessage): InboxMessage {
return { id: m.id, from: m.from, text: m.text, createdAt: m.createdAt, seen: m.seenAt !== null };
}
function countUnseen(queue: readonly StoredMessage[]): number {
let n = 0;
for (const m of queue) if (m.seenAt === null) n++;
return n;
}
function inboxBytes(queue: readonly StoredMessage[]): number {
let total = 0;
for (const m of queue) total += Buffer.byteLength(m.text, 'utf8');
return total;
}
function isStoredMessage(value: unknown): value is StoredMessage {
const m = value as Partial<StoredMessage> | null;
return (
!!m &&
typeof m.id === 'string' &&
(m.from === null || typeof m.from === 'string') &&
typeof m.text === 'string' &&
m.text.length > 0 &&
Buffer.byteLength(m.text, 'utf8') <= MAX_MESSAGE_BYTES &&
typeof m.createdAt === 'number' &&
Number.isFinite(m.createdAt) &&
(m.seenAt === null || typeof m.seenAt === 'number')
);
}
/** Process-wide store used by the routes and the server wiring. Tests construct their own. */
export const agentInbox = new AgentInbox();
+172 -50
View File
@@ -9,7 +9,7 @@
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
@@ -17,6 +17,7 @@ import {
AUTH_SESSION_TTL_MS,
MAX_AUTH_SESSIONS,
AUTH_FAILURE_MAX,
AUTH_IP_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
@@ -32,7 +33,7 @@ import {
lostWebviewFramePage,
LOST_FRAME_PAGE_CSP,
} from '../webview-proxy.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type UserRecord } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
// ownership helpers default to a synthetic admin (see route-helpers).
@@ -53,6 +54,8 @@ interface AuthState {
hookSecretFailures: StaleExpirationMap<string, number> | null;
/** Per-username Basic-auth failure bucket (multi-user only). */
userFailures: StaleExpirationMap<string, number> | null;
/** Per-(username, client IP) Basic-auth failure bucket (multi-user only). */
userIpFailures: StaleExpirationMap<string, number> | null;
}
/** Rate-limit response for a client that exceeded the failure cap. */
@@ -76,6 +79,22 @@ function parseBasicAuth(header?: string): { username: string; password: string }
}
}
/**
* Key of the multi-user in-flight verify map: a SHA-256 digest of (client IP,
* normalized username, password), so the plaintext password is never held as a key.
* The JSON array keeps the three parts unambiguous for any strings (a NUL-joined
* string is not, since a username may carry a NUL). Never logged or persisted, and
* gone from the map once its verify settles.
*/
function verifyFlightKey(clientIp: string, normUser: string, password: string): string {
return createHash('sha256')
.update(JSON.stringify([clientIp, normUser, password]))
.digest('hex');
}
/** What one Basic verify decided, shared by the request that ran it and every joiner. */
type BasicVerifyOutcome = { user: UserRecord } | { user: null; limited: boolean };
/**
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
* single-user and multi-user auth hooks so the security-critical logic has ONE
@@ -303,6 +322,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
qrAuthFailures: null,
hookSecretFailures: null,
userFailures: null,
userIpFailures: null,
};
// Always declare req.authUser so downstream reads are safe (single-user leaves it
@@ -350,13 +370,33 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
const hookSecretFailures = state.hookSecretFailures;
if (multiUser) {
// Per-username failure bucket: a botnet can't brute-force one account across
// many IPs, and one user behind a NAT can't lock out everyone else.
// Per-username failure bucket: failed guesses against one account answer 429
// from any IP once it fills, and one user behind a NAT can't lock out everyone
// else. Checked after the verify, so it never denies a correct password.
state.userFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
// Per-(username, IP) failure bucket: checked BEFORE the verify, so it is what
// caps verified guesses against one account from one source. Keyed on the pair,
// so other IPs can't lock the account out and two users behind one shared IP
// don't lock each other out. Same decay as the other buckets: it holds one key
// per (IP, username) pair whose verify failed or is still in flight, swept by
// the same TTL, so like them it grows only with scrypt verifies.
state.userIpFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(
app,
https,
authSessions,
authFailures,
hookSecretFailures,
state.userFailures,
state.userIpFailures,
basePath
);
return state;
}
@@ -465,8 +505,9 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
/**
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
* `username:password` against the user store, mints an identity-carrying cookie,
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
* decorates `req.authUser`, enforces the per-IP, per-(username, IP) and per-username
* rate limits, and the `mustChangePassword` lockbox. Identical logins in flight at once
* share one verify. The single-user hook above is left untouched.
*/
function registerMultiUserAuthHook(
app: FastifyInstance,
@@ -475,6 +516,7 @@ function registerMultiUserAuthHook(
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>,
userIpFailures: StaleExpirationMap<string, number>,
basePath = ''
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
@@ -515,6 +557,75 @@ function registerMultiUserAuthHook(
return false;
};
// Basic verifies in flight, by verifyFlightKey(). A request whose identical
// credentials from the same IP are already being verified awaits that verify
// instead of starting its own. Each entry is removed when its verify settles, so the
// map never holds more than the distinct verifies in flight.
const verifiesInFlight = new Map<string, Promise<BasicVerifyOutcome>>();
// The verify plus its once-only side effects, run by the first request for a key.
// A success clears the three buckets and records the login; a failure is counted
// here once (the per-IP and pair buckets already hold it: the caller reserved them).
const verifyOnce = async (
username: string,
password: string,
normUser: string,
clientIp: string,
pairKey: string
): Promise<BasicVerifyOutcome> => {
const result = await verifyPassword(username, password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
userIpFailures.delete(pairKey);
return { user };
}
// Failed guess: only the per-username bucket counts it here. Once that bucket
// reaches the cap, further FAILED attempts get 429 from any IP, but this path is
// only reached on a wrong password, so it can never deny a correct one. Its count
// is re-read after the await, so concurrent failures against one account each add one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
return { user: null, limited: uFail >= AUTH_FAILURE_MAX };
};
// Answer one request from a shared outcome. Every successful request, joiners
// included, gets its own cookie session under the usual capacity rules.
const sendBasicOutcome = (
req: FastifyRequest,
reply: FastifyReply,
outcome: BasicVerifyOutcome,
normUser: string
): void => {
if (outcome.user) {
const { user } = outcome;
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: req.ip,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
return;
}
if (outcome.limited) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
};
app.addHook('onRequest', async (req, reply) => {
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass' || bypass === 'rejected') return;
@@ -569,58 +680,69 @@ function registerMultiUserAuthHook(
return;
}
// 2. Basic Auth against the user store (scrypt verify).
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
// per-username bucket here; see below).
// 2. Basic Auth against the user store (scrypt verify). Three failure buckets:
// - per IP (AUTH_IP_FAILURE_MAX), checked first: bounds scrypt CPU cost from one
// source. It refuses even a correct password, so it is wider than
// AUTH_FAILURE_MAX: behind a shared egress IP (NAT, a tunnel) a narrow cap
// would let a few wrong guesses lock everyone out.
// - per (username, IP) (AUTH_FAILURE_MAX), checked BEFORE the verify: caps
// verified guesses against one account from one source. Only that IP is
// refused for that account; the same account from another IP still logs in.
// - per username (AUTH_FAILURE_MAX), checked AFTER the verify; see below.
// Neither pre-verify refusal is counted, since nothing was verified (and counting
// a locked pair's retries would fill the shared per-IP bucket for everyone).
// A request that passes both gates RESERVES its slot in both buckets before the
// scrypt await, as if it had already failed. Counting only after the verify let a
// concurrent burst pass the gates on the same stale read: 29 wrong guesses plus the
// right password, all in flight at once from one IP, logged in. A success deletes
// the reservation with the rest of the buckets; a failure is not counted again.
// A request whose IDENTICAL credentials from the same IP are already being verified
// joins that verify instead: it skips both gates, takes no slot, runs no scrypt and
// gets the same outcome, since it adds no guess and no CPU. Reserving for each of
// them refused a correct password with the slots that password itself had filled,
// which is every reload after a restart: cookie sessions live in memory, so the
// browser replays its cached Basic header on many parallel requests at once. A
// request with no verify in flight for its credentials is gated as normal.
const creds = parseBasicAuth(req.headers.authorization);
const normUser = creds ? creds.username.trim().toLowerCase() : '';
const flightKey = creds ? verifyFlightKey(clientIp, normUser, creds.password) : '';
const shared = creds ? verifiesInFlight.get(flightKey) : undefined;
if (shared) {
sendBasicOutcome(req, reply, await shared, normUser);
return;
}
const ipFail = authFailures.get(clientIp) ?? 0;
if (ipFail >= AUTH_FAILURE_MAX) {
if (ipFail >= AUTH_IP_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
const creds = parseBasicAuth(req.headers.authorization);
if (creds) {
const normUser = creds.username.trim().toLowerCase();
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
// per-username bucket before verifying let throwaway IPs lock out a known account
// (incl. admin) even with the correct password. A correct password must always
// win and self-heal both buckets, regardless of the username-failure count.
const result = await verifyPassword(creds.username, creds.password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: clientIp,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
// IPs never contain a newline, so this key is unambiguous for any username.
const pairKey = `${clientIp}\n${normUser}`;
const pairFail = userIpFailures.get(pairKey) ?? 0;
if (pairFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userIpFailures, pairKey);
return;
}
// Failed guess: count it against BOTH buckets. Once the per-username bucket
// reaches the cap, further FAILED attempts get 429 (throttles distributed
// brute-force), but this path is only reached on a wrong password, so it can
// never deny a correct one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
// Reserve both slots and publish this verify for joiners, from the values the
// gates just read. Correct only while nothing between those reads and these
// writes awaits: keep it that way.
authFailures.set(clientIp, ipFail + 1);
if (uFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
userIpFailures.set(pairKey, pairFail + 1);
// Security: the per-username bucket is consulted only AFTER the verify, and only
// FAILED attempts are throttled by it. Consulting it before verifying let
// throwaway IPs lock out a known account (incl. admin) even with the correct
// password. A correct password that passes the two gates above always wins and
// self-heals the per-IP, per-username and this pair's buckets.
const verify = verifyOnce(creds.username, creds.password, normUser, clientIp, pairKey);
verifiesInFlight.set(flightKey, verify);
let outcome: BasicVerifyOutcome;
try {
outcome = await verify;
} finally {
verifiesInFlight.delete(flightKey);
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
sendBasicOutcome(req, reply, outcome, normUser);
return;
}
+175 -76
View File
@@ -997,6 +997,12 @@ class CodemanApp {
this._sseLastMessageAt = 0;
this._sseStaleTimeoutMs = window.CodemanSseStale?.TIMEOUT_MS ?? 45000;
this._sseStaleWatchdog = null;
// Connect timeout: the watchdog only judges a 'connected' stream, and an
// attempt whose request hangs never opens AND never errors. Each attempt
// is bounded by this timer (armed in connectSSE, cleared on open, on
// failure and at the top of every connectSSE so timers never stack).
this._sseConnectTimeout = null;
this._sseConnectTimeoutMs = window.CodemanSseStale?.CONNECT_TIMEOUT_MS ?? 15000;
// Connection-loss UI (banner + full-screen overlay). The decision itself is
// pure and lives in constants.js (computeConnectionLossUi); these are just
@@ -2066,6 +2072,10 @@ class CodemanApp {
}
connectSSE() {
// First, before the offline early return: any path into connectSSE
// supersedes the previous attempt, and its deadline must go with it.
this._clearTimer('_sseConnectTimeout');
// Check if browser is offline
if (!navigator.onLine) {
this.setConnectionStatus('offline');
@@ -2109,7 +2119,21 @@ class CodemanApp {
const _sseParams = new URLSearchParams({ clientId: this._clientId });
const _sseFilterId = this._sseFilterSessionId(this.activeSessionId);
if (_sseFilterId) _sseParams.set('sessions', _sseFilterId);
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
const eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
this.eventSource = eventSource;
// Bound the attempt. A request that never gets response headers leaves
// the EventSource in CONNECTING with no `open` and no `error`, so without
// this the page waits forever. readyState OPEN covers the narrow race
// where the connection opened but its `open` callback has not run yet.
const connectTimeout = setTimeout(() => {
if (this._sseConnectTimeout !== connectTimeout) return;
this._sseConnectTimeout = null;
if (this.eventSource !== eventSource || eventSource.readyState === EventSource.OPEN) return;
console.warn(`[SSE] connection did not open within ${this._sseConnectTimeoutMs}ms, retrying`);
this._handleSseConnectionFailure(eventSource);
}, this._sseConnectTimeoutMs);
this._sseConnectTimeout = connectTimeout;
// Store all event listeners for cleanup on reconnect.
//
@@ -2138,6 +2162,7 @@ class CodemanApp {
};
this.eventSource.onopen = () => {
this._clearTimer('_sseConnectTimeout');
this.reconnectAttempts = 0;
// Start the liveness clock here, not at the first frame: the watchdog
// only ever fires while the status is 'connected', and this is the
@@ -2145,31 +2170,7 @@ class CodemanApp {
this._sseLastMessageAt = Date.now();
this.setConnectionStatus('connected');
};
this.eventSource.onerror = () => {
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.setConnectionStatus('disconnected');
} else {
this.setConnectionStatus('reconnecting');
}
// Close the failed connection before scheduling reconnect
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
this._clearTimer('sseReconnectTimeout');
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
// silent wait that long is indistinguishable from a hung app.
this._nextSseRetryAt = Date.now() + delay;
this._updateConnectionLossUi();
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
};
this.eventSource.onerror = () => this._handleSseConnectionFailure(eventSource);
// Create stable handler wrappers once (reused across reconnects so
// removeEventListener always matches the original reference)
@@ -2332,6 +2333,38 @@ class CodemanApp {
this._sseStaleWatchdog = setInterval(() => this._checkSseStale(), 5000);
}
/**
* Close a failed SSE attempt (onerror, or the connect timeout) and schedule
* the normal exponential-backoff retry.
*/
_handleSseConnectionFailure(eventSource) {
// A late error or timeout from an EventSource that has since been
// replaced must not close or reschedule the connection that replaced it.
if (this.eventSource !== eventSource) return;
this._clearTimer('_sseConnectTimeout');
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.setConnectionStatus('disconnected');
} else {
this.setConnectionStatus('reconnecting');
}
// Close the failed connection before scheduling reconnect
this.eventSource = null;
eventSource.close();
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
this._clearTimer('sseReconnectTimeout');
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
// silent wait that long is indistinguishable from a hung app.
this._nextSseRetryAt = Date.now() + delay;
this._updateConnectionLossUi();
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
}
/**
* Force a reconnect if the SSE stream has gone quiet while still claiming to
* be connected. Called by the 5s watchdog and on tab-visible.
@@ -2402,8 +2435,6 @@ class CodemanApp {
if (data.parentSessionId) this.markConnectionLineEntering?.('lineage:' + data.id);
this.renderSessionTabs();
this.updateCost();
// Start stats polling when first session appears
if (this.sessions.size === 1) this.startSystemStatsPolling();
if (this._urlSessionId === data.id) this._selectUrlSession();
}
@@ -2459,8 +2490,8 @@ class CodemanApp {
this.renderSessionTabs();
this.renderRalphStatePanel(); // Update ralph panel after session deleted
this.renderProjectInsightsPanel(); // Update project insights panel after session deleted
// Stop stats polling when no sessions remain
if (this.sessions.size === 0) this.stopSystemStatsPolling();
// Stats polling keeps going with no session left: the header shows CPU, MEM and the opt-in readings
// regardless, and would sit at "--" otherwise.
}
// SSE wrappers — skip terminal events while WebSocket owns active terminal I/O.
@@ -3351,11 +3382,15 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
// fetches wide in the fallback case, so this guard is not optional. The
// pane count is recorded only past it: a refresh that lost its owner is
// older than whatever the switch back read (it would have overwritten a
// fresh 0 with a stale count and held the paging fallback off a hollow
// pane), and an error envelope is not a capture at all.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -3680,7 +3715,9 @@ class CodemanApp {
seg('mo', pct(usage?.monthly), false),
].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
// data-short is what the narrowest header densities show instead of the name (header-divider.js).
const short = { Claude: 'CL', Codex: 'CX', Copilot: 'CP' }[provider] ?? provider.slice(0, 2).toUpperCase();
const label = labelled ? `<span data-short="${short}" class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
@@ -5025,12 +5062,9 @@ class CodemanApp {
// survive reloads and SSE reconnects (methods in approvals-ui.js).
this.seedApprovals?.();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
} else {
this.stopSystemStatsPolling();
}
// Poll the header stats whether or not a session is open (fetchSystemStats skips the call while the
// stats pill is hidden); starting restarts the interval, so only start it once.
if (!this.systemStatsInterval) this.startSystemStatsPolling();
// CRITICAL: Clean up all floating windows before loading new subagents
// This prevents memory leaks from ResizeObservers, EventSources, and DOM elements
@@ -7245,24 +7279,7 @@ class CodemanApp {
// the axis a user reaches for there.
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
// drag model honest), so a DOM-order walk steps around the screen instead of
// down it: ArrowDown from the top card lands wherever that session happens to
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
const tabs = this._visibleTabRows(container);
const currentIndex = tabs.indexOf(document.activeElement);
// Enter or Space activates the tab
@@ -7312,6 +7329,52 @@ class CodemanApp {
}
}
/**
* The strip's rows (session and web tabs) in the order the eye reads them,
* for any layout except the grouped tree (see _tabTreeItems).
*/
_visibleTabRows(container) {
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
// drag model honest), so a DOM-order walk steps around the screen instead of
// down it: ArrowDown from the top card lands wherever that session happens to
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
return tabs;
}
/**
* Session ids in the order the session list DRAWS them: case boxes, state
* groups, a sorted rail and named groups all reorder the screen while
* `sessionOrder` stays canonical. Only the phone swipe walks this order
* (`nextSession({ visual: true })`); Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep
* the canonical one, since grouping is a render layer. Web tabs are skipped.
*/
_stripSessionOrder() {
const container = document.getElementById('sessionTabs');
if (!container) return this.sessionOrder;
const rows =
container.getAttribute('role') === 'tree'
? this._tabTreeItems(container).filter((el) => el.classList.contains('session-tab'))
: this._visibleTabRows(container);
// An empty list is a real answer (every row filtered out, a tree collapsed
// to nothing): the swipe then goes nowhere, like the strip. Only a missing
// strip falls back to the canonical order.
return rows.map((row) => row.dataset.id).filter((id) => id && this.sessions.has(id));
}
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
_activateTabRow(row) {
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
@@ -9238,10 +9301,23 @@ class CodemanApp {
// `force` is the user pressing "Load full history" (#258): they asked once,
// explicitly, so the scroll-gesture cooldown does not apply. The downgrade
// guard below still does — a forced pull must not destroy history either.
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
// A refused pull latches 60 s because the browser then held MORE than tmux
// (the downgrade guard below). A tab switch that replayed a hollow tail has
// voided that premise: one screen in the browser while the pane holds
// scrollback, the state `_maybePageCliTranscript` hands to this pull instead
// of paging the CLI. Hold such a buffer to the normal cooldown only, or the
// gesture is dead for up to a minute.
const unloaded =
(this._paneHistoryLines?.get(sessionId) ?? 0) > 0 && (this.terminal?.buffer?.active?.baseY ?? 0) === 0;
const cooldown = !unloaded && this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
// The session-id check below cannot see a tab switch that came BACK during
// the fetch (A -> B -> A): the id matches again while `selectSession` is
// replaying its own capture, and the reset below would land in the middle
// of that write. The select generation moves on every switch, so it can.
const selectGen = this._selectGeneration;
try {
const requestStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
@@ -9253,7 +9329,13 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
// A completion that outlived a tab switch is older than whatever that
// switch recorded about the pane (a switch back re-reads it): keep its
// count out of the memory, or it can overwrite a fresh count with a stale one.
// An error envelope (a JSON 5xx, `success: false`) is not a capture:
// recording its empty data would forget the count and hand the next
// gesture back to the paging fallback, the bug this pull exists to avoid.
if (selectGen === this._selectGeneration && capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -9269,8 +9351,9 @@ class CodemanApp {
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
// history into the terminal the user is now looking at, or this session's
// over the load a switch back has started.
if (!buffer || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
// A bounded window no longer than the browser's buffer buys nothing, and
// resetting to rewrite it would jump the viewport on every scroll that
@@ -9342,7 +9425,7 @@ class CodemanApp {
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
// may be tens of MB; caching it would replay that whole payload again on
// the next tab switch before the normal 1MB tail fetch replaces it.
@@ -9364,6 +9447,10 @@ class CodemanApp {
// reached only from a scroll-up gesture, so being dragged down is the
// exact opposite of what the user asked for.
this._syncStickyScrollBaseline();
// The prompt row moved with the rewrite: re-position the local-echo
// overlay there, as the refresh path does (a phone swipe reaches this
// pull with unsent text on the overlay).
this._localEchoOverlay?.rerender();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
@@ -9411,7 +9498,10 @@ class CodemanApp {
/**
* Remember how much scrollback tmux holds for a session's pane, from any
* terminal response. Called whether or not the response was written, since
* it describes the PANE, not the payload. A response without the field (a
* it describes the PANE, not the payload. Three responses are skipped, since
* none is the newest word on the pane: an error envelope (`success: false`)
* on any path, a refresh that lost its owner, and a history pull whose select
* generation moved during the fetch. A response without the field (a
* byte-history fallback, an older server) forgets it, so the next tab switch
* goes back to the bounded tail: unknown never counts as empty.
*/
@@ -9943,7 +10033,10 @@ class CodemanApp {
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// An error envelope (a JSON 5xx: _fetchTerminalCapture does not check
// `res.ok`) is not a capture: recording its empty data would forget the
// count the pane last reported, as in the refresh and the history pull.
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
@@ -10612,23 +10705,29 @@ class CodemanApp {
}
}
nextSession() {
/** `{ visual: true }` walks the order the strip draws (the phone swipe), see _stripSessionOrder. */
nextSession(options = {}) {
// With the tile grid open, Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles.
if (this._cycleTileFocus?.(1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[nextIndex]);
const order = options.visual ? this._stripSessionOrder() : this.sessionOrder;
// An active session the list does not show (filtered out, or none active on
// the home screen) steps in at the first row; a lone row that is not the
// active one is still a destination.
const currentIndex = order.indexOf(this.activeSessionId);
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % order.length;
if (!order[nextIndex] || order[nextIndex] === this.activeSessionId) return;
this.selectSession(order[nextIndex]);
}
prevSession() {
prevSession(options = {}) {
if (this._cycleTileFocus?.(-1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const prevIndex = (currentIndex - 1 + this.sessionOrder.length) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[prevIndex]);
const order = options.visual ? this._stripSessionOrder() : this.sessionOrder;
// As above, from the other end: a hidden or absent active session steps in
// at the last row (the old arithmetic landed on the second to last).
const currentIndex = order.indexOf(this.activeSessionId);
const prevIndex = currentIndex < 0 ? order.length - 1 : (currentIndex - 1 + order.length) % order.length;
if (!order[prevIndex] || order[prevIndex] === this.activeSessionId) return;
this.selectSession(order[prevIndex]);
}
// ═══════════════════════════════════════════════════════════════
+11
View File
@@ -828,6 +828,15 @@ function formatKeepWarmStatus(status, now, statusLine) {
// Pure: no DOM, no timers, no side effects. `now` is passed in.
const SSE_STALE_TIMEOUT_MS = 45000; // three missed 15s heartbeats
// The watchdog above cannot see a connect attempt that never opens: an
// EventSource whose request gets no response headers (a resumed phone on a
// VPN, a proxy that accepted the socket and went silent) stays CONNECTING
// without firing `open` or `error`, and its status is never 'connected'.
// connectSSE() bounds each attempt by this much and then retries it through
// the normal backoff. Generous next to a healthy open (well under a second)
// so a slow link is not cut off mid-handshake.
const SSE_CONNECT_TIMEOUT_MS = 15000;
function computeSseStale(input) {
const {
lastMessageAt = null,
@@ -1545,6 +1554,7 @@ if (typeof window !== 'undefined') {
window.CodemanSseStale = {
compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
CONNECT_TIMEOUT_MS: SSE_CONNECT_TIMEOUT_MS,
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
@@ -1914,6 +1924,7 @@ const SSE_EVENTS = {
CASE_CREATED: 'case:created',
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_RENAMED: 'case:renamed',
CASE_ORDER_CHANGED: 'case:order-changed',
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
+209
View File
@@ -0,0 +1,209 @@
/**
* @fileoverview A draggable divider on the left edge of the header's right-hand toolbar.
*
* Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the
* toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a
* device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely
* positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in
* the header (sidebar, vertical rail, phones).
*
* @loadorder after app.js; self-contained, no CodemanApp dependency.
*/
(function () {
const STORAGE_KEY = 'codeman-header-right-width';
const MIN_WIDTH = 160;
const MIN_TABS_ROOM = 240;
const KEY_STEP = 16;
/** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */
function clampWidth(width, headerWidth, brandWidth) {
const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM);
return Math.round(Math.min(max, Math.max(MIN_WIDTH, width)));
}
/**
* How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of
* the labels go: window labels first, then provider names (shortened), then, in the Compact style, the
* percentages (the rings stay, and the chip's tooltip keeps the numbers).
*/
function densityFor(width) {
if (width >= 560) return '';
if (width >= 400) return 'tight';
if (width >= 280) return 'dense';
return 'tiny';
}
function readStored() {
try {
const value = Number(localStorage.getItem(STORAGE_KEY));
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
}
function writeStored(width) {
try {
if (width === null) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, String(width));
} catch {
/* storage blocked: the width lasts until the page reloads */
}
}
function init() {
const header = document.querySelector('.header');
const right = document.getElementById('headerRight');
if (!header || !right || right.querySelector('.header-divider')) return;
const brand = header.querySelector('.header-brand');
const root = document.documentElement;
const handle = document.createElement('div');
handle.className = 'header-divider';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)');
handle.setAttribute('aria-valuemin', String(MIN_WIDTH));
handle.tabIndex = 0;
right.insertBefore(handle, right.firstChild);
// The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading
// that needs more room), and the request is what comes back when the window grows again.
let wanted = readStored();
const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 });
const current = () => right.getBoundingClientRect().width;
/** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */
function contentMinimum() {
const style = getComputedStyle(right);
const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth);
let widest = 0;
for (const child of right.children) {
if (child === handle || child.offsetParent === null) continue;
widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth);
}
return Math.ceil(widest + frame);
}
function setDensity(width) {
const density = densityFor(width);
if (density) root.setAttribute('data-header-density', density);
else root.removeAttribute('data-header-density');
}
/** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */
function settle() {
if (wanted === null) {
root.style.removeProperty('--header-right-width');
root.removeAttribute('data-header-right-width');
root.removeAttribute('data-header-density');
handle.removeAttribute('aria-valuenow');
handle.removeAttribute('aria-valuemax');
return;
}
const { header: h, brand: b } = bounds();
let clamped = clampWidth(wanted, h, b);
root.setAttribute('data-header-right-width', 'set');
// A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum.
for (let i = 0; i < 3; i++) {
root.style.setProperty('--header-right-width', clamped + 'px');
setDensity(clamped);
const floor = contentMinimum();
if (clamped >= floor) break;
clamped = floor;
}
handle.setAttribute('aria-valuenow', String(clamped));
handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b)));
}
/** Several changes in one frame (a chip refill, a resize) settle once. */
let settleFrame = 0;
function scheduleSettle() {
if (wanted === null || settleFrame) return;
settleFrame = requestAnimationFrame(() => {
settleFrame = 0;
settle();
});
}
/** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */
function relayout() {
window.app?.updateTabOverflowMode?.();
window.app?.updateConnectionLines?.();
}
function apply(width) {
wanted = width === null ? null : Math.max(MIN_WIDTH, Math.round(width));
writeStored(wanted);
settle();
relayout();
}
// Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row
// can start reporting later, a header button can be switched on): re-settle when any child changes size.
const resizeObserver = new ResizeObserver(scheduleSettle);
const watch = () => {
for (const child of right.children) if (child !== handle) resizeObserver.observe(child);
};
watch();
new MutationObserver(() => {
watch();
scheduleSettle();
}).observe(right, { childList: true });
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
handle.setPointerCapture?.(event.pointerId);
document.body.classList.add('header-divider-dragging');
const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0;
let moveFrame = 0;
let lastX = event.clientX;
const onMove = (e) => {
lastX = e.clientX;
if (moveFrame) return;
moveFrame = requestAnimationFrame(() => {
moveFrame = 0;
wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight);
settle();
});
};
const finish = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
handle.removeEventListener('lostpointercapture', finish);
if (moveFrame) cancelAnimationFrame(moveFrame);
moveFrame = 0;
document.body.classList.remove('header-divider-dragging');
if (wanted !== null) apply(wanted);
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
handle.addEventListener('lostpointercapture', finish);
});
handle.addEventListener('dblclick', () => apply(null));
handle.addEventListener('keydown', (event) => {
if (event.key === 'Home') {
apply(null);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
// Step from the width on screen, not the request, so a key held past the floor never leaves a dead zone.
apply(current() + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP));
} else {
return;
}
event.preventDefault();
});
window.addEventListener('resize', scheduleSettle);
settle();
}
window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+21
View File
@@ -86,6 +86,17 @@
'Web tab settings': '网页标签设置',
'New group': '新建分组',
'Rename group': '重命名分组',
'Rename case': '重命名案例',
Rename: '重命名',
'New name': '新名称',
'letters, numbers, - and _': '字母、数字、- 和 _',
'Also rename the folder on disk': '同时重命名磁盘上的文件夹',
'A case under the cases folder is renamed together with its folder.':
'案例文件夹下的案例会连同其文件夹一起重命名。',
'Stop sessions and rename': '停止会话并重命名',
'Enter a different name.': '请输入不同的名称。',
'Use only letters, numbers, hyphens and underscores.': '只能使用字母、数字、连字符和下划线。',
'Failed to rename the case.': '重命名案例失败。',
'Move group up': '上移分组',
'Move group down': '下移分组',
'Delete group': '删除分组',
@@ -94,6 +105,8 @@
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'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)': '新会话使用的智能体(按左/右方向键切换)',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
@@ -449,6 +462,8 @@
'Header Displays': '顶部栏显示',
'Font Controls': '字体控制',
'System Stats': '系统状态',
'GPU Stats': 'GPU 状态',
'CPU MHz': 'CPU 频率',
'Lifecycle Log': '生命周期日志',
'Response Viewer': '回复查看器',
'Attachments Button': '附件按钮',
@@ -1332,6 +1347,12 @@
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Case renamed to "(.+)"$/, (_m, name) => `案例已重命名为“${name}”`],
[/^New name for "(.+)"$/, (_m, name) => `“${name}”的新名称`],
[
/^These sessions have the folder open: (.+)\. They will be closed \(their tabs go away and nothing is restarted\) before the folder is renamed\.$/,
(_m, list) => `这些会话正在使用该文件夹:${list}。重命名前将关闭它们(其标签页会消失,不会重新启动)。`,
],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
+63
View File
@@ -178,6 +178,30 @@
</div>
<span class="stat-value" id="statMem">--</span>
</div>
<div class="stat-item stat-item--host" id="statGpuItem" hidden>
<span class="stat-ring" id="statGpuRing" aria-hidden="true"></span>
<span class="stat-label">GPU</span>
<div class="stat-bar">
<div class="stat-bar-fill stat-bar-cpu" id="statGpuBar"></div>
</div>
<span class="stat-value" id="statGpu">--%</span>
</div>
<div class="stat-item stat-item--host" id="statVramItem" hidden>
<span class="stat-ring" id="statVramRing" aria-hidden="true"></span>
<span class="stat-label">VRAM</span>
<div class="stat-bar">
<div class="stat-bar-fill stat-bar-cpu" id="statVramBar"></div>
</div>
<span class="stat-value" id="statVram">--%</span>
</div>
<div class="stat-item stat-item--host stat-item--ringless" id="statGpuMhzItem" hidden>
<span class="stat-label">GPU</span>
<span class="stat-value" id="statGpuMhz">-- MHz</span>
</div>
<div class="stat-item stat-item--host stat-item--ringless" id="statCpuMhzItem" hidden>
<span class="stat-label">CPU</span>
<span class="stat-value" id="statCpuMhz">-- MHz</span>
</div>
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
@@ -2097,6 +2121,8 @@
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="GPU 8%"><input type="checkbox" id="appSettingsShowGpuStats"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 21v-4M12 21v-4M17 21v-4M7 7V3M17 7V3"/></svg><span>GPU Stats</span></label>
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="3400 MHz"><input type="checkbox" id="appSettingsShowCpuMhz"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>CPU MHz</span></label>
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
<label class="set-chip" data-preview="header" data-preview-order="4"><input type="checkbox" id="appSettingsShowResponseViewer"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Response Viewer</span></label>
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
@@ -2145,6 +2171,13 @@
<label class="set-chip" data-preview="header" data-preview-order="12"><input type="checkbox" id="appSettingsShowUltracodeAgents"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg><span>Ultracode Agents</span></label>
<label class="set-chip" data-preview="float" data-preview-order="1"><input type="checkbox" id="appSettingsUltracodeFloatingWindows"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><circle cx="6" cy="6.5" r="0.6"/></svg><span>Ultracode Windows</span></label>
</div>
<div class="set-row" data-search="agent inbox messages persist restart disk codeman agent post">
<div class="set-row-text">
<span class="set-row-label">Keep agent inbox across restarts</span>
<span class="set-row-desc">Saves pending <code>codeman agent post</code> messages to <code>agent-inbox.json</code> in the data dir (readable by you only), so they survive a server restart. Off by default; turning it off deletes the file. Messages expire after 12 hours either way.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentInboxPersist"><span class="slider"></span></label>
</div>
</div>
</div>
@@ -3792,6 +3825,35 @@
</div>
</div>
<!-- Rename a case (and its folder): opened by the Manage tab's pencil button, see renameCase() in session-ui.js.
Placed after the case modal so it stacks above it. -->
<div class="modal" id="renameCaseModal">
<div class="modal-backdrop" onclick="app.closeRenameCase()"></div>
<div class="modal-content modal-sm">
<div class="modal-header">
<h3>Rename case</h3>
<button class="modal-close" onclick="app.closeRenameCase()" aria-label="Cancel">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label for="renameCaseInput" id="renameCaseLabel">New name</label>
<input type="text" id="renameCaseInput" class="set-input" maxlength="100" autocomplete="off" spellcheck="false"
placeholder="letters, numbers, - and _" onkeydown="if (event.key === 'Enter') app.submitRenameCase()">
</div>
<div class="form-row hidden" id="renameCaseFolderRow">
<label class="checkbox-row"><input type="checkbox" id="renameCaseFolder"> Also rename the folder on disk</label>
<span class="form-hint" id="renameCaseFolderHint" data-i18n-skip></span>
</div>
<p class="form-hint" id="renameCaseNote">A case under the cases folder is renamed together with its folder.</p>
<p class="form-hint hidden" id="renameCaseError" role="alert" style="color: var(--danger, #e5534b);"></p>
</div>
<div class="modal-footer">
<button class="btn-toolbar" onclick="app.closeRenameCase()">Cancel</button>
<button class="btn-toolbar btn-primary" id="renameCaseSubmit" onclick="app.submitRenameCase()">Rename</button>
</div>
</div>
</div>
<!-- Mobile Case Picker (Bottom Sheet) -->
<div class="modal mobile-case-picker" id="mobileCasePickerModal">
<div class="modal-backdrop" onclick="app.closeMobileCasePicker()"></div>
@@ -4345,6 +4407,7 @@
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="header-divider.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="terminal-tile.js"></script>
<script defer src="terminal-split.js"></script>
+4 -2
View File
@@ -862,7 +862,9 @@ const SwipeHandler = {
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
// Valid swipe detected. Swipe right -> previous session, left -> next.
if (deltaX > 0) app.prevSession();
else app.nextSession();
// In the order the strip draws, which a grouped layout ('case', 'state', a
// sorted rail) makes differ from the canonical session order.
if (deltaX > 0) app.prevSession({ visual: true });
else app.nextSession({ visual: true });
},
};
+220 -7
View File
@@ -424,6 +424,7 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !search) return;
this.commandPaletteActiveIndex = 0;
this._resetCommandPaletteAgent();
search.value = '';
modal.classList.add('active');
@@ -465,6 +466,11 @@ Object.assign(CodemanApp.prototype, {
});
search?.addEventListener('keydown', async (e) => {
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && this._commandPaletteArrowCyclesAgent(e)) {
e.preventDefault();
this._cycleCommandPaletteAgent(e.key === 'ArrowRight' ? 1 : -1);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
this.moveCommandPaletteSelection(1);
@@ -494,7 +500,22 @@ Object.assign(CodemanApp.prototype, {
}
});
// Clicking an agent arrow must leave focus in the search box, or the
// Left/Right/Enter keys stop reaching the palette after the first click.
list?.addEventListener?.('mousedown', (e) => {
if (e.target?.closest?.('[data-palette-agent-cycle]')) e.preventDefault?.();
});
list?.addEventListener?.('click', (e) => {
// The agent arrows sit inside the new-session row: they change the agent
// and must not also activate (launch) the row they are drawn in.
const cycle = e.target?.closest?.('[data-palette-agent-cycle]');
if (cycle) {
e.preventDefault?.();
e.stopPropagation?.();
this._cycleCommandPaletteAgent(cycle.dataset.paletteAgentCycle === 'prev' ? -1 : 1);
return;
}
const row = e.target?.closest?.('[data-command-index]');
if (!row) return;
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
@@ -535,18 +556,110 @@ Object.assign(CodemanApp.prototype, {
},
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const caseName = this._commandPaletteCaseName(query);
const agent = this._commandPaletteAgent(caseName);
const mode = agent?.id || this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
type: 'new-session',
caseName,
mode,
title: 'New session',
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
subtitle: `Run ${agent?.label || labels[mode] || mode} in ${caseName}`,
};
},
/** The case a new session from the palette starts in: the case the query names, else the Run button's case. */
_commandPaletteCaseName(query = '') {
return this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
},
/** The case of the new-session row on screen (the palette's last render), for the agent switch. */
_commandPaletteRowCase() {
const row = (this.commandPaletteItems || []).find((item) => item?.type === 'new-session');
return row ? row.caseName : this._commandPaletteCaseName(document.getElementById('commandPaletteSearch')?.value || '');
},
/**
* The agents the new-session row can start in `caseName`: the server-injected
* CLI catalog's enabled entries that the toolbar Run menu offers for that case,
* in catalog order. An agent entry goes through the Run menu's own rule,
* _runModeAvailableForCase (session-ui.js): the host's CLIs for a local or
* remote case, the container's for a Docker case. A non-agent entry (Shell) is
* never gated, as in the Run menu. The vm test harness may load this file
* without session-ui.js, and then the host's isCliAvailable() decides. The ids
* are DATA from the registry, never a list kept here. Empty on a page rendered
* without the catalog; that and a single entry both leave the row on the Run
* button's mode (see _commandPaletteAgent).
*/
_commandPaletteAgentChoices(caseName = this._commandPaletteRowCase()) {
const catalog = typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
const offered = (id) =>
typeof this._runModeAvailableForCase === 'function'
? this._runModeAvailableForCase(id, caseName)
: (this.isCliAvailable?.(id) ?? true);
return catalog
.filter((cli) => cli && typeof cli.id === 'string' && cli.enabled && (cli.kind !== 'agent' || offered(cli.id)))
.map((cli) => ({ id: cli.id, label: (typeof cli.label === 'string' && cli.label.trim()) || cli.id }));
},
/**
* The agent the new-session row will start in `caseName`: the one picked with
* the switch since the palette opened, else the Run button's mode, else the
* first offered. Null when fewer than two are offered: with one entry there is
* nothing to pick, so the row keeps the Run button's mode as it did before the
* switch existed, and a Run mode the case cannot start fails the way the Run
* button would, instead of the palette quietly starting the one entry offered
* (often Shell).
*/
_commandPaletteAgent(caseName = this._commandPaletteRowCase()) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return null;
const runMode = this.runMode || this._runMode;
return (
choices.find((c) => c.id === this._commandPaletteAgentId) || choices.find((c) => c.id === runMode) || choices[0]
);
},
/**
* Each time the palette opens it forgets the last pick, so the row starts on
* the Run button's mode (see _commandPaletteAgent). That default is resolved
* per render, not here: the query can move the row to another case, which may
* offer the Run button's mode when the first case did not.
*/
_resetCommandPaletteAgent() {
this._commandPaletteAgentId = null;
},
_cycleCommandPaletteAgent(delta) {
const caseName = this._commandPaletteRowCase();
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return;
const at = Math.max(0, choices.findIndex((c) => c.id === this._commandPaletteAgent(caseName)?.id));
this._commandPaletteAgentId = choices[(((at + delta) % choices.length) + choices.length) % choices.length].id;
this.renderCommandPalette();
},
/**
* 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.
*/
_commandPaletteArrowCyclesAgent(e) {
const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0];
if (active?.type !== 'new-session') return false;
if (this._commandPaletteAgentChoices(active.caseName).length < 2) return false;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false;
const input = e.target;
const value = typeof input?.value === 'string' ? input.value : '';
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;
},
_findCommandPaletteCaseMatch(query = '') {
const needle = query.trim().toLowerCase();
if (!needle || !Array.isArray(this.cases)) return null;
@@ -600,19 +713,40 @@ Object.assign(CodemanApp.prototype, {
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
const agent = item.type === 'new-session' ? this._renderCommandPaletteAgentPicker(item.mode, item.caseName) : '';
return `
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
<button class="command-palette-item${active}${browse}${agent ? ' command-palette-item--agent' : ''}" type="button" data-command-index="${index}">
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
<span class="command-palette-text">
<span class="command-palette-title">${escapeHtml(item.title)}</span>
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
</span>
</span>${agent}
</button>
`;
})
.join('');
},
/**
* The new-session row's agent switch: arrows either side of the agent's logo
* and name. Spans, not buttons: the row itself is a <button>. The switch is
* aria-hidden (no nested buttons; the row's subtitle already names the agent),
* and its arrows work by click through data-palette-agent-cycle. Nothing is
* drawn when there is nothing to choose between.
*/
_renderCommandPaletteAgentPicker(mode, caseName) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return '';
const agent = choices.find((c) => c.id === mode) || choices[0];
return `
<span class="command-palette-agent" aria-hidden="true" title="Agent for the new session (Left/Right to change)">
<span class="command-palette-agent-arrow" data-palette-agent-cycle="prev">&lsaquo;</span>
<span class="run-mode-dot ${escapeHtml(agent.id)}" aria-hidden="true"></span>
<span class="command-palette-agent-label">${escapeHtml(agent.label)}</span>
<span class="command-palette-agent-arrow" data-palette-agent-cycle="next">&rsaquo;</span>
</span>`;
},
moveCommandPaletteSelection(delta) {
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
if (!items.length) return;
@@ -655,7 +789,16 @@ Object.assign(CodemanApp.prototype, {
caseSelect.value = item.caseName;
}
}
await this.run();
// A one-shot launch of the picked agent: the same temporary _runMode swap
// the custom-model entries and worktree launches use, so run() keeps its
// launch lock and per-mode paths and the Run button's mode is untouched.
const previousRunMode = this._runMode;
if (item.mode) this._runMode = item.mode;
try {
await this.run();
} finally {
this._runMode = previousRunMode;
}
}
},
@@ -6716,13 +6859,29 @@ Object.assign(CodemanApp.prototype, {
// Skip polling when system stats display is hidden
const statsEl = document.getElementById('headerSystemStats');
if (!statsEl || statsEl.style.display === 'none') return;
// One request at a time: the poll fires every 2 s, and a slow answer (a cold WSL sampler, a hung
// server) must not pile up requests behind it.
if (this._systemStatsPending) return;
this._systemStatsPending = true;
try {
const res = await fetch('/api/system/stats');
// Opt-in readings are only requested while their header setting is on (default off).
const settings = this.loadAppSettingsFromStorage?.() ?? {};
const wanted = ['gpu', settings.showGpuStats, 'cpumhz', settings.showCpuMhz];
const query = [];
for (let i = 0; i < wanted.length; i += 2) if (wanted[i + 1] === true) query.push(`${wanted[i]}=1`);
const res = await fetch('/api/system/stats' + (query.length ? '?' + query.join('&') : ''), {
signal:
typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(8000)
: undefined,
});
const stats = await res.json();
this.updateSystemStatsDisplay(stats.data);
} catch (err) {
// Silently fail - system stats are not critical
} finally {
this._systemStatsPending = false;
}
},
@@ -6767,6 +6926,60 @@ Object.assign(CodemanApp.prototype, {
memBar.classList.add('medium');
}
}
// After the MEM block, so nothing in the optional readings can stop MEM updating.
this._updateHostSensors(stats);
},
/**
* The opt-in readings after CPU and MEM: GPU load, GPU memory and GPU clock (the GPU Stats setting) and the CPU clock. An item
* shows only while its setting is on AND the host reported it, so a machine without a readable GPU just
* has nothing extra in the header.
*/
_updateHostSensors(stats) {
const settings = this.loadAppSettingsFromStorage?.() ?? {};
const show = (id, visible) => {
const el = this.$(id);
if (el) el.hidden = !visible;
};
const fill = (valueId, ringId, barId, percent, text) => {
const value = this.$(valueId);
if (value) {
value.textContent = text;
value.classList.toggle('high', percent > 80);
}
this._setStatRing(ringId, percent);
const bar = this.$(barId);
if (bar) bar.style.width = `${Math.min(100, percent)}%`;
};
const gpu = settings.showGpuStats === true ? stats.gpu : undefined;
show('statGpuItem', Boolean(gpu));
if (gpu) fill('statGpu', 'statGpuRing', 'statGpuBar', gpu.percent, `${gpu.percent}%`);
// Graphics memory: the amount in use whenever it is known, with a ring only when the total is too (an
// integrated GPU under WSL, where Windows reports usage but the share of RAM is a guess, may have none).
show('statVramItem', Boolean(gpu && gpu.memUsedMB !== undefined));
if (gpu && gpu.memUsedMB !== undefined) {
const used = `${(gpu.memUsedMB / 1024).toFixed(1)}G`;
const ring = this.$('statVramRing');
// Ringless, the Compact style's rings-only step hides the whole item (styles.css) instead of an empty one.
this.$('statVramItem')?.classList.toggle('stat-item--ringless', gpu.memPercent === undefined);
if (gpu.memPercent !== undefined) {
fill('statVram', 'statVramRing', 'statVramBar', gpu.memPercent, used);
if (ring) ring.style.display = '';
} else {
const vram = this.$('statVram');
if (vram) vram.textContent = used;
if (ring) ring.style.display = 'none';
}
}
// The GPU clock is a third GPU Stats item (the host may not report one: Windows has none for Intel).
show('statGpuMhzItem', Boolean(gpu && gpu.mhz !== undefined));
const gpuMhzEl = gpu && gpu.mhz !== undefined ? this.$('statGpuMhz') : null;
if (gpuMhzEl) gpuMhzEl.textContent = `${gpu.mhz} MHz`;
const cpuMhz = settings.showCpuMhz === true && stats.cpuMhz !== undefined ? stats.cpuMhz : undefined;
show('statCpuMhzItem', cpuMhz !== undefined);
const cpuMhzEl = cpuMhz !== undefined ? this.$('statCpuMhz') : null;
if (cpuMhzEl) cpuMhzEl.textContent = `${cpuMhz} MHz`;
},
/**
+154 -23
View File
@@ -782,38 +782,18 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
// A DOCKER case runs its agents INSIDE the container, so host CLI
// availability answers the wrong question: the host may have no claude at
// all while the container ships one, and gating on the host hides a mode
// that would have worked. Adoption records what the container really has
// (`availableModes`); an owned container runs our base image, which ships
// every CLI, so an absent list means "do not gate" rather than "nothing".
// Same source every run* path reads the selected case from.
const caseName = document.getElementById('quickStartCase')?.value;
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
const isDocker = activeCase?.location === 'docker';
// Prefer a LIVE probe over the value stored at attach time: a container's
// CLIs can be installed or removed long after the case was linked, and a
// case linked before that field existed has none at all.
const containerModes = isDocker
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
: null;
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
// An unreachable container hides every agent mode and explains why, instead
// of silently offering modes that cannot start.
//
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
// state before the first session — the launch chain creates and starts it — so
// reporting it as a fault hid every agent mode on a freshly linked Docker case
// behind "start it yourself first", for a container Codeman was about to create.
// An unreachable (adopted) container hides every agent mode and explains why,
// instead of silently offering modes that cannot start; see _runModeAvailableForCase.
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
for (const option of menu.querySelectorAll('.run-mode-option[data-mode]')) {
const mode = option.dataset.mode;
if (!mode || mode === 'shell') continue;
let available;
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
else available = this.isCliAvailable(mode);
option.style.display = available ? 'flex' : 'none';
option.style.display = this._runModeAvailableForCase(mode, caseName) ? 'flex' : 'none';
}
this._renderRunModeNotice(menu, probeError);
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
@@ -832,6 +812,44 @@ Object.assign(CodemanApp.prototype, {
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/**
* Does the case named `caseName` offer agent run mode `mode`? The ONE rule
* behind the Run menu's entries (_refreshRunModeAvailability) and the Cmd+K
* new-session row's agents (_commandPaletteAgentChoices, panels-ui.js), so the
* two never disagree on what a case can start. Shell needs no CLI and is
* 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
* _dockerCaseModes, which this reads. Until then (a probe still in flight)
* the stored availableModes decide, else nothing is gated.
*/
_runModeAvailableForCase(mode, caseName) {
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
if (activeCase?.location === 'docker') {
// A DOCKER case runs its agents INSIDE the container, so host CLI
// availability answers the wrong question: the host may have no claude at
// all while the container ships one, and gating on the host hides a mode
// that would have worked. Adoption records what the container really has
// (`availableModes`); an owned container runs our base image, which ships
// every CLI, so an absent list means "do not gate" rather than "nothing".
//
// An unreachable container offers no agent mode. ⚠️ Only an ADOPTED case
// ever records that error: for an OWNED case a missing container is the
// normal state before the first session (the launch chain creates and
// starts it), so reporting it as a fault hid every agent mode on a freshly
// linked Docker case behind "start it yourself first", for a container
// Codeman was about to create.
if (this._dockerCaseProbeError?.[caseName]) return false;
// Prefer a LIVE probe over the value stored at attach time: a container's
// CLIs can be installed or removed long after the case was linked, and a
// case linked before that field existed has none at all.
const containerModes = this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null;
return containerModes ? containerModes.includes(mode) : true;
}
return this.isCliAvailable(mode);
},
/** Render every enabled agent entry from the server's registry projection. */
renderRegistryRunOptions() {
const container = document.getElementById('runModeCliOptions');
@@ -4957,6 +4975,12 @@ Object.assign(CodemanApp.prototype, {
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
${
c.location === 'docker' || c.location === 'remote'
? ''
: `<button class="case-manage-btn" onclick="app.renameCase(${escapeHtml(JSON.stringify(c.name))})"
title="Rename case (and its folder)">&#x270E;</button>`
}
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>
@@ -4989,6 +5013,113 @@ Object.assign(CodemanApp.prototype, {
await this.saveCaseOrder(reordered.map(c => c.name));
},
/**
* Rename a case and its folder (POST /api/cases/:name/rename), in the app's own dialog.
*
* A linked case shows a checkbox for renaming the real folder too (unticked = the name only). When live
* sessions have the folder open the server refuses and lists them; the dialog then lists them and its button
* becomes "Stop sessions and rename", so they are only stopped after a second, explicit press. Errors from
* the server (a taken name, a folder another program has open) stay inside the dialog so the name can be fixed.
*/
renameCase(name) {
const info = (this.cases || []).find((c) => c.name === name);
this._renameCaseState = { name, linked: Boolean(info?.linked), stop: false, busy: false };
const input = document.getElementById('renameCaseInput');
const label = document.getElementById('renameCaseLabel');
if (label) label.textContent = `New name for "${name}"`;
if (input) input.value = name;
const row = document.getElementById('renameCaseFolderRow');
row?.classList.toggle('hidden', !info?.linked);
const box = document.getElementById('renameCaseFolder');
if (box) box.checked = false;
const hint = document.getElementById('renameCaseFolderHint');
if (hint) hint.textContent = info?.linked ? `${info.path} (unticked: only the name in this list changes)` : '';
document.getElementById('renameCaseNote')?.classList.toggle('hidden', Boolean(info?.linked));
this._renameCaseNotice('');
const submit = document.getElementById('renameCaseSubmit');
if (submit) submit.textContent = 'Rename';
document.getElementById('renameCaseModal')?.classList.add('active');
input?.focus?.();
input?.select?.();
},
closeRenameCase() {
document.getElementById('renameCaseModal')?.classList.remove('active');
this._renameCaseState = null;
},
/** Show (or clear, with '') a message inside the rename dialog. */
_renameCaseNotice(message) {
const el = document.getElementById('renameCaseError');
if (!el) return;
el.textContent = message;
el.classList.toggle('hidden', !message);
},
async submitRenameCase() {
const state = this._renameCaseState;
if (!state || state.busy) return;
const name = state.name;
const newName = (document.getElementById('renameCaseInput')?.value || '').trim();
if (!newName || newName === name) {
this._renameCaseNotice('Enter a different name.');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(newName)) {
this._renameCaseNotice('Use only letters, numbers, hyphens and underscores.');
return;
}
const body = { newName };
if (state.linked) body.renameFolder = Boolean(document.getElementById('renameCaseFolder')?.checked);
if (state.stop) body.stopSessions = true;
state.busy = true;
const submit = document.getElementById('renameCaseSubmit');
if (submit) submit.disabled = true;
try {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({ success: false, error: 'Unexpected response' }));
if (data.requiresStop) {
const list = (data.sessions || []).map((s) => `${s.name}`).join(', ');
state.stop = true;
this._renameCaseNotice(`These sessions have the folder open: ${list}. They will be closed (their tabs go away and nothing is restarted) before the folder is renamed.`);
if (submit) submit.textContent = 'Stop sessions and rename';
return;
}
if (!data.success) {
// A rename that failed after closing sessions says so: their tabs are gone either way.
const closed = Number(data.stoppedSessions) || 0;
const message = data.error || 'Failed to rename the case.';
this._renameCaseNotice(
closed > 0 ? `${message} (${closed} session${closed === 1 ? ' was' : 's were'} already closed.)` : message
);
return;
}
this.closeRenameCase();
this.showToast(`Case renamed to "${newName}"`, 'success');
// The per-case run options are stored under the case name in this browser.
try {
const stored = localStorage.getItem('caseSettings_' + name);
if (stored !== null) {
localStorage.setItem('caseSettings_' + newName, stored);
localStorage.removeItem('caseSettings_' + name);
}
} catch { /* storage blocked: the options fall back to their defaults */ }
const select = document.getElementById('quickStartCase');
const current = select?.value;
await this.loadQuickStartCases(current === name ? newName : current);
this.renderCaseManageList();
} catch (err) {
this._renameCaseNotice('Failed to rename the case: ' + err.message);
} finally {
state.busy = false;
if (submit) submit.disabled = false;
}
},
async deleteCase(name) {
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
return;
+14 -1
View File
@@ -514,6 +514,9 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
// Opt-in host readings after CPU and MEM: all default OFF.
document.getElementById('appSettingsShowGpuStats').checked = settings.showGpuStats ?? defaults.showGpuStats ?? false;
document.getElementById('appSettingsShowCpuMhz').checked = settings.showCpuMhz ?? defaults.showCpuMhz ?? false;
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
@@ -536,6 +539,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
// Agent inbox snapshot on disk: synced, default OFF.
document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === 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.
@@ -2904,6 +2909,8 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showGpuStats: document.getElementById('appSettingsShowGpuStats').checked,
showCpuMhz: document.getElementById('appSettingsShowCpuMhz').checked,
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
@@ -2921,6 +2928,7 @@ Object.assign(CodemanApp.prototype, {
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
...this._configBackupRetentionPayload(_prev),
agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -3234,6 +3242,9 @@ Object.assign(CodemanApp.prototype, {
showCronButton: _crb,
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
showGitStatus: _sgs,
// Opt-in header readings (GPU Stats, CPU MHz): per-device and absent from the .strict() schema.
showGpuStats: _sgp,
showCpuMhz: _scm,
gitStatusTree: _gst,
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
@@ -4113,6 +4124,8 @@ Object.assign(CodemanApp.prototype, {
// Header visibility - hide everything on mobile
showFontControls: false,
showSystemStats: false,
showGpuStats: false,
showCpuMhz: false,
showTokenCount: false,
showCost: false,
// Panel visibility - hide panels on mobile (not enough space)
@@ -4880,7 +4893,7 @@ Object.assign(CodemanApp.prototype, {
*/
_displaySettingKeySet() {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'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',
+157
View File
@@ -2249,6 +2249,132 @@ html[data-tab-orientation='vertical'].tab-rail-compact
margin-left: auto;
padding-left: 0.75rem;
border-left: 1px solid var(--glass-border);
position: relative;
}
/* Draggable edge of the toolbar (header-divider.js). Absolute, so it takes no layout room and the
existing border-left stays the visible line. */
.header-divider {
position: absolute;
top: 0;
bottom: 0;
left: -6px;
width: 12px;
cursor: col-resize;
touch-action: none;
z-index: 2;
}
.header-divider:hover,
.header-divider:focus-visible {
background: color-mix(in srgb, var(--accent) 24%, transparent);
outline: none;
}
.header-divider:focus-visible {
box-shadow: inset 0 0 0 2px var(--accent);
}
/* Everything below applies only where the tabs share the header with the toolbar, i.e. where the handle is
visible to undo it: not in the sidebar layout, the vertical rail or a solo window, and not on a phone. */
/* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */
@media (min-width: 769px) {
html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
body:not(.solo-mode)
.header-right {
flex: 0 0 var(--header-right-width);
width: var(--header-right-width);
flex-wrap: wrap;
justify-content: flex-end;
align-content: center;
row-gap: 0.25rem;
min-height: var(--header-height);
}
/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js):
tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings.
The prefix outweighs the per-style rules further down, so every header-stats style follows it.
The opt-in host readings (.stat-item--host: GPU, VRAM and the two clocks) keep their labels at every
step: their values look like CPU's and MEM's ("40%", "3.2G", "1800 MHz" beside "4200 MHz"), so the
label is the only thing that says which reading is which. */
html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
body:not(.solo-mode)
:is(.header-system-stats .stat-item:not(.stat-item--host) .stat-label, .header-plan-usage .pu-label) {
display: none;
}
html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
body:not(.solo-mode)
:is(.header-system-stats, .header-plan-usage) {
gap: 8px;
padding: 0 8px;
}
html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not(
[data-session-list='sidebar']
)
body:not(.solo-mode)
.header-plan-usage
.pu-provider {
font-size: 0;
}
html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not(
[data-session-list='sidebar']
)
body:not(.solo-mode)
.header-plan-usage
.pu-provider::after {
content: attr(data-short);
font-size: 0.6rem;
}
/* Rings-only is a Compact-style reading: the other styles have no rings, so they keep their values. */
html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not(
[data-session-list='sidebar']
)
body:not(.solo-mode)
:is(.header-system-stats .stat-value, .header-plan-usage .pu-val) {
display: none;
}
/* With its value gone, a reading with no ring (the two clocks, and VRAM while its total is unknown:
.stat-item--ringless, toggled by panels-ui.js) would be an empty item that still takes a gap. */
html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not(
[data-session-list='sidebar']
)
body:not(.solo-mode)
.header-system-stats
.stat-item--ringless {
display: none;
}
}
body.header-divider-dragging,
body.header-divider-dragging * {
cursor: col-resize !important;
user-select: none !important;
}
/* Only where the tabs share the header with the toolbar. */
html[data-tab-orientation='vertical'] .header-divider,
html[data-session-list='sidebar'] .header-divider,
body.solo-mode .header-divider {
display: none;
}
@media (max-width: 768px) {
.header-divider {
display: none;
}
}
/* The opt-in host readings (GPU, VRAM, MHz) start hidden and are shown by panels-ui.js once the host
reports them; a plain `display` on .stat-item would override the attribute, and the Tiles style sets its own
(a grid) at a higher specificity, so the selector carries the style attribute to outrank it. */
html[data-header-stats] .header-system-stats .stat-item[hidden] {
display: none;
}
/* Connection Indicator */
@@ -8970,6 +9096,37 @@ kbd {
font-size: 0.72rem;
}
/* New-session row: the agent it will start, switchable with Left/Right or the arrows */
.command-palette-item--agent {
grid-template-columns: 26px minmax(0, 1fr) auto;
}
.command-palette-agent {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.2rem;
font-size: 0.78rem;
color: var(--text);
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
white-space: nowrap;
}
.command-palette-agent-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--text-muted);
border-radius: 4px;
font-size: 1rem;
line-height: 1;
}
.command-palette-agent-arrow:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.1);
}
/* COD-192: "Browse all sessions…" escape hatch — visually secondary */
.command-palette-item--browse {
margin-top: 4px;
+8 -1
View File
@@ -1173,9 +1173,13 @@
// pages the CLI's own transcript with PageUp/PageDown. Every gate is the
// primary pane's own, asked for THIS pane (its terminal, its session, never
// the active one), so the CLI rules stay in terminal-ui.js and this file
// names no CLI. The gesture rules (first-event page, notch accumulation,
// names no CLI. The gesture rules (first-event page, notch debt,
// pinch and sideways-swipe consume) are the shared pure
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
// The primary pane's unloaded-scrollback fall-through (`paneHistoryLines`
// > 0 with baseY 0, after a restart's tail replay) has no twin here: a tile
// loads tmux's rendered history (`full=1`), so it does not share that
// tail-select failure, and its non-shell wheel has no pull to fall through to.
// Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
@@ -1209,6 +1213,9 @@
// session, which is usually not the active one: a page key would move its
// selector. Consumed, so xterm does not scroll the rows under it either.
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
// A Codex selection menu is not an alert, but PageUp/PageDown move its selection (see
// CodemanTerminalInput.isCodexSelectionMenu), so it consumes the wheel the same way.
if (app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })) return true;
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
+135 -29
View File
@@ -98,10 +98,20 @@
const PAGE_KEY_GESTURE_GAP_MS = 150;
// ...but not for sub-row jitter (0.1 row, ~2px)...
const PAGE_KEY_MIN_START_ROWS = 0.1;
// ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px,
// 4 rows) accumulates toward half a screen as before; paging at once on it made
// slow notches, each its own gesture past the gap, page a full screen apiece.
// ...for any opening event under one page. A trackpad-sized opener (under this many rows) and a
// mouse-wheel notch (100px, 4 rows) both page at once; this size is what tells them apart for the debt
// (see PAGE_KEY_NOTCH_DEBT_MS). The signal is the opener's size, not the device: a touch contact whose
// first touchmove is this many rows or more is treated as a notch too, and so is a brisk trackpad
// flick that opens with a large event. Both are fine, and both get the longer debt.
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
// A mouse-wheel notch used to just accumulate toward half a screen, so a mouse showed nothing for the
// first 5 notches (7 on Firefox's 3-line notches) and a gesture of a few notches seemed dead. The first
// notch of a gesture now pre-pays its page like a trackpad flick does, and the notches that follow repay
// it: the same gesture sends the same pages, the first one sooner. The old objection was slow notches,
// each its own gesture past the gap, paging a full screen apiece, so a notch's debt outlives the pauses
// between notches for this long (a trackpad's lasts PAGE_KEY_DEBT_MS). It applies to any opener of
// PAGE_KEY_IMMEDIATE_MAX_ROWS rows or more, whatever device it came from.
const PAGE_KEY_NOTCH_DEBT_MS = 3000;
// A pre-paid page's debt outlives the gesture gap for this long when the next
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
@@ -205,19 +215,22 @@
// (`ev.touchId`, set by the touch paths per touchstart and carried by that
// contact's momentum; a wheel event has none, so wheel and touch never
// share a gesture), or, for events outside any contact, after quiet:
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid
// page is still owed in the same direction. One contact is never split by
// time, so a slow drag (one line per 170 ms) is one gesture;
// - a new gesture's trackpad-sized opening event (at least
// PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE,
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS (PAGE_KEY_NOTCH_DEBT_MS
// when a notch-sized opener pre-paid it) while a pre-paid page is still owed
// in the same direction. One contact is never split by time, so a slow drag
// (one line per 170 ms) is one gesture;
// - a new gesture's opening event (at least PAGE_KEY_MIN_START_ROWS and
// under one page: a trackpad flick, or a mouse notch) pages AT ONCE,
// pre-paid: the half screen it skipped is owed back by the rest of the
// gesture, so the rate stays one page per half screen. A debt left by a
// pre-paid page dies with its gesture;
// - a direction change inside a gesture (a reversal within the gap, or
// within the same contact) drops a pre-paid debt and accumulates, never
// pre-paying, so a +/-3 px trackpad wobble cannot page back and forth;
// - everything else, a wheel notch included, accumulates through
// pageKeysForTravel, carried across notches.
// - a mouse-wheel notch (any opening event under one page, past the trackpad size) pre-pays its page
// the same way, and its debt lasts PAGE_KEY_NOTCH_DEBT_MS, so the notches that follow repay it
// however slowly they come instead of each paging a screen of its own;
// - everything else accumulates through pageKeysForTravel, carried across notches.
// Returns { state, keys } with keys '' when nothing is due.
function pageKeysForGesture(state, ev, lines, rows, now) {
const prev = state || {};
@@ -239,7 +252,8 @@
gestureStart = false; // the same contact, however long it has been
} else {
const quiet = now - prev.lastAt;
gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS);
const debtMs = prev.notch ? PAGE_KEY_NOTCH_DEBT_MS : PAGE_KEY_DEBT_MS;
gestureStart = quiet > (prepaid && !reversed ? debtMs : PAGE_KEY_GESTURE_GAP_MS);
}
if (gestureStart || reversed) {
// A pre-paid debt belongs to its gesture and its direction.
@@ -248,20 +262,34 @@
}
if (gestureStart) {
const size = Math.abs(lines);
if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
// Plain travel left over from the gesture before (what was left once its debt was repaid) is
// not dropped by the new opener: it counts toward the page, so a gesture made of notches pages
// once per `perPage` rows of travel, as it did before the first notch paged at once.
const carried = pending * dir > 0 ? pending : 0;
if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage && (carried + lines) * dir < perPage) {
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
return {
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId },
state: {
pending: carried + lines - dir * perPage,
lastAt: now,
dir,
prepaid: true,
notch: size >= PAGE_KEY_IMMEDIATE_MAX_ROWS,
touchId,
},
keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN,
};
}
}
const step = pageKeysForTravel(pending, lines, rows);
// A debt carries the sign opposite to its direction; once the travel reaches
// or crosses zero it is repaid, and what is left is plain travel (so the
// one-second debt lifetime no longer applies).
// or crosses zero it is repaid, and what is left is plain travel (so neither
// debt lifetime, PAGE_KEY_DEBT_MS or PAGE_KEY_NOTCH_DEBT_MS, applies any more).
if (prepaid && step.pending * dir >= 0) prepaid = false;
return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys };
return {
state: { pending: step.pending, lastAt: now, dir, prepaid, notch: prepaid && !!prev.notch, touchId },
keys: step.keys,
};
}
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
@@ -319,6 +347,41 @@
}
}
// A Codex selection menu on screen: the "Implement this plan?" prompt, the /model, /permissions and
// /review pickers, an approval, a question or the trust prompt. Each draws its options as numbered rows
// with the selected one marked `› N. ` (a question indents it two spaces, and its notes field reads
// `› Add notes`) and a key-hint footer (`enter select · esc back`, `enter
// continue · esc back`, `Press enter to confirm ...`). Codex uses PageUp and PageDown there to move
// the SELECTION (measured on 0.164.0: PageDown from option 1 lands on option 3), so a wheel turned
// into page keys would walk the cursor through the options, one notch onto "Yes, implement this plan".
// Read off the live screen rows (the last `rows` rows of the active buffer), counting non-blank rows from
// the bottom: the trust prompt is drawn at the TOP of an otherwise blank screen.
const CODEX_MENU_OPTION_RE = /^ {0,2}› (?:\d+\. |Add notes)/;
// A wrapped key hint continues on rows that start in lower case (`cancel`, `esc to interrupt`) or with a separator.
const CODEX_MENU_HINT_WRAP_RE = /^ {2}[a-z←|]/;
const CODEX_MENU_FOOTER_RE = /^ {1,4}(?:Press enter to confirm|enter (?:select|continue)|tab (?:to add|or esc to clear) notes)/;
function isCodexSelectionMenu(terminal) {
try {
const buf = terminal.buffer.active;
// The non-blank rows from the bottom of the screen up. A menu's key hints are the last thing drawn, and a
// narrow pane wraps them over more rows that start in lower case: nothing else may sit under them, so
// an answer that quotes the hints (above the composer, a tip and a status row) is not a menu.
const rows = [];
for (let y = buf.baseY + terminal.rows - 1; y >= buf.baseY; y--) {
const text = buf.getLine(y)?.translateToString(true) ?? '';
if (text.trim()) rows.push(text);
}
let footerAt = 0;
while (footerAt < rows.length && !CODEX_MENU_FOOTER_RE.test(rows[footerAt])) {
if (!CODEX_MENU_HINT_WRAP_RE.test(rows[footerAt])) return false;
footerAt++;
}
return footerAt < rows.length && rows.slice(footerAt + 1).some((text) => CODEX_MENU_OPTION_RE.test(text));
} catch {
return false;
}
}
// Screen row of the cursor, for the local-echo prompt finders: xterm's cursorY is
// baseY-relative, so a viewport parked above the bottom shifts it. Null when off screen.
function cursorViewportRow(terminal) {
@@ -404,6 +467,7 @@
isComposerNavKey,
classifyPredictInput,
isCodexComposerRow,
isCodexSelectionMenu,
cursorViewportRow,
CODEX_COMPOSER_ROW_RE,
BRACKETED_PASTE_START,
@@ -425,6 +489,7 @@
PAGE_KEY_MIN_START_ROWS,
PAGE_KEY_IMMEDIATE_MAX_ROWS,
PAGE_KEY_DEBT_MS,
PAGE_KEY_NOTCH_DEBT_MS,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -5883,7 +5948,8 @@ Object.assign(CodemanApp.prototype, {
// a fix. Scoping the setting away from those modes would be the other
// option, but it would override an explicit user choice; instead the caller
// falls through to _maybePageCliTranscript, so the gesture still pages the
// CLI's transcript and the setting keeps meaning exactly what it says.
// CLI's transcript (or loads unloaded tmux scrollback, see there) and the
// setting keeps meaning exactly what it says.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
@@ -6038,27 +6104,33 @@ Object.assign(CodemanApp.prototype, {
*
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* `baseY === 0`) plus a false forwarding gate, so a session with real local
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
* "give me local scrollback" gesture and must keep that meaning.
* scrollback is never touched, and neither is one whose scrollback tmux still
* holds but the last replay did not carry (`paneHistoryLines` > 0): that one
* falls through to the local path, whose scroll-to-top pull loads it. Shift is
* excluded on purpose: it is the explicit "give me local scrollback" gesture
* and must keep that meaning.
*
* The FIRST event of a trackpad-sized gesture (opening under
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
* The FIRST event of a gesture (any opening event under one page: a trackpad
* flick, or a mouse notch) pages at once. Waiting for half a screen of
* travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick
* never sent a key at all. That page is pre-paid: the travel it skipped is
* owed back by the rest of the gesture, so the rate stays one page per
* `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact
* (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is
* still owed), a reversal after the gap or a tab switch starts a new gesture;
* (PAGE_KEY_GESTURE_GAP_MS; while a same-direction page is still owed,
* PAGE_KEY_DEBT_MS, or PAGE_KEY_NOTCH_DEBT_MS when a notch-sized opener pre-paid
* it), a reversal after the gap or a tab switch starts a new gesture;
* one contact is never split by time, and a quick reversal never pre-pays. A
* larger opening event is a wheel notch and accumulates as before, so slow
* notches still page once per `perPage` rows.
* mouse-wheel notch pages at once too, and its debt (PAGE_KEY_NOTCH_DEBT_MS) is repaid by the
* notches after it, so a mouse pages once per `perPage` rows of travel with the
* first page on the first notch, not the fifth.
*
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
* the touch path locks its own axis before it gets here), and any gesture while
* the session has an open dialog (tab alert 'action', set by a pending
* permission_prompt or elicitation_dialog), so a page key never reaches its
* selector.
* permission_prompt or elicitation_dialog) or while a Codex selection menu is on
* screen (the "Implement this plan?" prompt, a /model picker; see
* isCodexSelectionMenu), so a page key never reaches its selector.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
@@ -6083,6 +6155,28 @@ Object.assign(CodemanApp.prototype, {
this._logScrollRouting('page-keys-dialog-open');
return true;
}
// Codex's own selection menus (the "Implement this plan?" prompt among them) are not alerts, but PageUp
// and PageDown move their selection, so the wheel is consumed there too.
if (this._codexMenuOpen()) {
this._logScrollRouting('page-keys-menu-open');
return true;
}
// A hollow buffer whose PANE still holds tmux scrollback is unloaded, not
// hollow: after a server restart a tab switch replays a byte tail that is
// only the attach repaint (baseY 0), and paging from there moved an inline
// Claude's composer cursor instead of its transcript. The remembered
// `paneHistoryLines` (tmux `#{history_size}`) says a `full=1` pull can add
// it, so fall through to local scrolling, whose top-of-buffer pull fetches
// it (_maybeLoadMoreHistoryOnScroll -> _maybeRefetchFullHistory). 0 and
// unknown keep paging. Page keys already queued belong to the abandoned
// route and go with the gesture debt. Not logged as a decision of its own
// (it would alternate with the caller's line every frame until the pull
// lands): the caller's `local-scrollback` line carries `paneHistoryLines`.
if ((this._paneHistoryLines?.get(this.activeSessionId) ?? 0) > 0) {
this._pageKeyGesture = null;
this._wheelSgrQueue = '';
return false;
}
// Leftover travel and the gesture in progress belong to the tab they were made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
@@ -6102,6 +6196,14 @@ Object.assign(CodemanApp.prototype, {
return true;
},
// Is a Codex selection menu showing in this pane (or the pane `target` names: a tile passes its own
// terminal and session)? See isCodexSelectionMenu.
_codexMenuOpen(target = {}) {
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
if (session?.mode !== 'codex') return false;
return window.CodemanTerminalInput.isCodexSelectionMenu(target.terminal || this.terminal);
},
/**
* One line in the console saying WHY a scroll gesture went where it went.
*
@@ -6124,16 +6226,20 @@ Object.assign(CodemanApp.prototype, {
// the DECSETs), so the CLI's real tracking state is reported separately.
const cliTracking = session?.cliMouseTracking === true;
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
// What tmux still holds for this pane (the last capture's `paneHistoryLines`):
// `localScrollbackRows=0` beside a positive count is an UNLOADED buffer,
// which _maybePageCliTranscript hands to the local path instead of paging.
const paneHistory = this._paneHistoryLines?.get(sessionId);
const signature =
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` +
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}`;
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}|${(paneHistory ?? 0) > 0}`;
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
this._scrollRoutingLogged.set(sessionId, signature);
console.log(
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` +
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})`
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY}, paneHistoryLines=${paneHistory ?? 'unknown'})`
);
},
+9 -9
View File
@@ -1,13 +1,13 @@
{
"version": "1.44.0",
"version": "1.45.0",
"highlights": [
"Config backups (#661)",
"Keep an idle Claude session's prompt cache warm (#666, opt-in)",
"A default tab group per case (#676)",
"A File Viewer you can resize, with Back and Forward (#659)",
"Copilot's premium-request quota in the plan-usage chip (#662)",
"More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668)",
"Multi-line prompts keep their line breaks (#670)",
"A warning for llama.cpp slots that split the context too thin (#658)"
"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)"
]
}
+80 -7
View File
@@ -11,7 +11,8 @@ import fs from 'node:fs/promises';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
import { ApiErrorCode, createErrorResponse, type ApiResponse, type AuthUser, type SessionState } from '../types.js';
import type { WaitCapacityError } from './session-wait-registry.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
@@ -324,11 +325,33 @@ function sessionNotFoundError(sessionId: string): Error & { statusCode: number;
* Look up a session by ID or throw a structured error.
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
*
* When `req` is passed in multi-user mode, a session the caller does not own is
* reported as NOT_FOUND (never 403), so existence of other users' sessions is not
* leaked. Single-user / admin callers are unaffected.
* In multi-user mode a session the caller does not own is reported as NOT_FOUND
* (never 403), so existence of other users' sessions is not leaked. Single-user /
* admin callers are unaffected (`canAccessOwned` short-circuits when multi-user is off).
*
* `req` is REQUIRED. When it was optional, forgetting it compiled cleanly and
* silently performed an UNSCOPED lookup. A caller with no request in flight must
* say so at the call site via {@link findSessionUnscopedOrFail}.
*/
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req: FastifyRequest): Session {
return lookupSessionOrFail(ctx, sessionId, req);
}
/**
* Look up a session WITHOUT ownership scoping. Deliberately verbose and greppable:
* every call is a decision to skip the multi-user ownership check and should carry
* a comment saying why (e.g. a startup path with no request in flight). Inside a
* request handler use `findSessionOrFail(ctx, id, req)` instead.
*
* It has no production callers today: every lookup in `src/` is request-scoped. It
* exists so the next genuinely request-less caller has somewhere to go other than
* making `req` optional again.
*/
export function findSessionUnscopedOrFail(ctx: SessionPort, sessionId: string): Session {
return lookupSessionOrFail(ctx, sessionId, undefined);
}
function lookupSessionOrFail(ctx: SessionPort, sessionId: string, req: FastifyRequest | undefined): Session {
const session = ctx.sessions.get(sessionId);
if (!session) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
@@ -343,15 +366,18 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: Fas
* return, so this returns the persisted record instead. Same ownership
* enforcement, same 404-not-403 leak protection — this is that function's
* missing other half, not a separate check reimplemented inline.
*
* `req` is REQUIRED for the same reason as on `findSessionOrFail`: an optional
* one would let a caller that forgot it retire another user's row in multi-user mode.
*/
export function findPersistedSessionOrFail(
store: { getSession(id: string): SessionState | null },
sessionId: string,
req?: FastifyRequest
req: FastifyRequest
): SessionState {
const persisted = store.getSession(sessionId);
if (!persisted) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
if (!canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
return persisted;
}
@@ -627,3 +653,50 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev
});
}
}
// ========== Long-poll helpers (shared by the wait routes and the inbox routes) ==========
/**
* Map a waiter-cap rejection to the code that tells the caller the truth.
*
* The two caps mean different things and warrant different recovery: `session` is
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
* documented as "Session is busy") sent an agent off to a different session to hit
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
* later", and clients and proxies already treat 429 that way.
*/
export function waitCapacityResponse(err: WaitCapacityError): ApiResponse<never> {
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
// The registry's message already names the scope and the limit; passing it through
// verbatim keeps the wording in one place.
return createErrorResponse(code, err.message);
}
/**
* An `AbortController` that fires when the CLIENT goes away, and only then.
*
* Freeing an abandoned waiter matters because the documented pattern is a loop of
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
* every iteration until the cap is hit and an innocent session reports busy. Same for
* any proxy that cuts the connection.
*
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
* as soon as the request body has finished streaming, which on a POST is BEFORE the
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
* instantly and silently kills the feature (it survives on GET only because a GET has
* no body to finish). `reply.raw` emits `'close'` both when the response completes
* and when the socket dies, and `writableFinished` is what tells those apart: true
* only if the response actually went out. The guard is load-bearing, not defensive.
*
* `app.inject()` never emits `'close'` at all, so this is only observable over real
* HTTP — which is why the regression test for it binds a port.
*/
export function abortOnClientHangUp(reply: FastifyReply): AbortController {
const controller = new AbortController();
reply.raw.on('close', () => {
if (!reply.raw.writableFinished) controller.abort();
});
return controller;
}
+212 -1
View File
@@ -10,7 +10,7 @@ import { FastifyInstance } from 'fastify';
import { existsSync, lstatSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
import { exec } from 'node:child_process';
import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { join, resolve, basename, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type {
@@ -30,6 +30,7 @@ import {
LinkCaseSchema,
CaseOrderSchema,
CaseDefaultGroupSchema,
RenameCaseSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
@@ -52,6 +53,15 @@ import {
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import {
CaseRenameError,
isCaseSessionStarting,
moveCaseFolder,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../../case-rename.js';
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
@@ -1747,6 +1757,207 @@ export function registerCaseRoutes(
}
});
// ========== Rename Case ==========
/**
* Rename a case: its name and, with it, its folder.
*
* - A case under the cases directory IS its folder name, so it always moves.
* - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only
* with `renameFolder: true` (it can be shared with other tools).
* - Remote and Docker cases are not renamable here: their folder lives on another host or in a container
* whose name derives from the case name.
*
* ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live
* sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which
* case they are ended first. The folder is held by an in-process lock for the whole operation, so a
* session cannot start in it half-moved.
*/
app.post('/api/cases/:name/rename', async (req, reply) => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
const body = parseBody(RenameCaseSchema, req.body);
const newName = body.newName;
const fail = (status: number, code: ApiErrorCode, message: string, extra: Record<string, unknown> = {}) => {
reply.code(status);
return { ...createErrorResponse(code, message), ...extra };
};
if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one');
// Remote and Docker cases first, so a same-named local case cannot be confused for them.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) ||
dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner))
) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here');
}
const casesBase = resolveCasesDir(user);
const linkedCases = await readLinkedCases();
const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req));
const oldPath = isLinked ? linkedCases[name] : join(casesBase, name);
// The new name must be free in every namespace the list merges.
const localTaken = existsSync(join(casesBase, newName));
const taken =
localTaken ||
Boolean(linkedCases[newName]) ||
remoteCases.some((c) => c.name === newName) ||
dockerCases.some((c) => c.name === newName);
if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`);
const state = await probePath(oldPath);
if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
// An alias-only rename never touches the folder, so an unreachable one does not block it.
if (state === 'unknown' && (!isLinked || body.renameFolder === true)) {
return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true }));
}
const moveFolder = !isLinked || body.renameFolder === true;
const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName);
if (moveFolder && newPath !== oldPath) {
// probePath follows symlinks, so a dangling one reads as absent; moveCaseFolder would still refuse it
// (it checks with lstat), but only after the sessions below were stopped. The probe answered for this
// very path, so the lstat after it is not a first touch of an unreachable mount.
const target = await probePath(newPath, { pastCap: true });
const danglingLink =
target === 'absent' &&
(await fs.lstat(newPath).then(
() => true,
() => false
));
if (target !== 'absent' || danglingLink) {
return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`);
}
}
// Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running.
// Remote sessions carry the remote path as their workingDir, so they are not this folder's.
const heldRoot = resolve(oldPath);
const open = moveFolder
? Array.from(ctx.sessions.values()).filter((s) => {
if (s.remote) return false;
const dir = resolve(s.workingDir);
return dir === heldRoot || dir.startsWith(heldRoot + sep);
})
: [];
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
// 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.
if (moveFolder && isCaseSessionStarting(oldPath)) {
return fail(409, ApiErrorCode.CONFLICT, 'A session is starting in this case. Try again in a moment.', {
renameCode: 'BUSY',
});
}
if (open.length > 0 && !body.stopSessions) {
return fail(
409,
ApiErrorCode.CONFLICT,
`${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`,
{ requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) }
);
}
// Sessions really ended so far: an error after `stopSessions` still says how many are gone.
let stopped = 0;
try {
await withCaseLock(oldPath, newPath, async () => {
for (const s of open) {
await ctx.cleanupSession(s.id, true, 'case_rename');
stopped++;
}
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;
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
)
);
}
}
if (originalLinks) {
const links = { ...originalLinks };
delete links[name];
links[newName] = newPath;
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
} catch (err) {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
throw err;
}
}
} 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(() => {});
}
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
() => false
);
if (!movedBack) {
throw new Error(`${getErrorMessage(err)}. The folder could not be moved back and is now at ${newPath}.`);
}
}
throw err;
}
if (moveFolder) await moveClaudeHistory(oldPath, newPath);
// The owner's default session group is keyed by case name: carry it over, best effort.
const layoutOwner = ownerLayoutKey(ownerFor(req));
try {
const { groupId } = await ctx.tabLayouts.caseDefaultGroup(layoutOwner, name);
if (groupId) await ctx.tabLayouts.setCaseDefaultGroup(layoutOwner, newName, groupId);
} catch {
/* the group is a convenience; the rename already happened */
}
await ctx.tabLayouts.forgetCaseDefaultGroup(layoutOwner, name).catch(() => undefined);
});
ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder });
return {
success: true,
data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length },
};
} catch (err) {
if (err instanceof CaseRenameError) {
const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400;
const code =
err.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: err.code === 'BUSY'
? ApiErrorCode.CONFLICT
: ApiErrorCode.OPERATION_FAILED;
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
}
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Agent inbox routes: post, read (optionally long-polling), ack, and an
* owner-scoped summary of counts. Store semantics live in `agent-inbox.ts`.
*
* Who is calling decides what a read or an ack does, so it is resolved here and never
* taken from the body. `resolveParentSessionId()` reads the `X-Codeman-Parent-Session`
* header `codeman agent` sends on every request and accepts it only for a live session
* the caller can see that has the same owner as the receiver (exact id, or a unique
* prefix of at least 8). Anything else is "no session": a post is from the user, and a
* read is a peek. What that buys: in multi-user mode a caller cannot name another
* owner's session (an admin, who can see every session, can act as any session of
* the receiver's owner, as admins can everywhere else). In single-user mode every
* local process holds the same credentials, so the header is a hint the client sets
* about itself.
*
* - `POST /api/sessions/:id/inbox` stores a message. `from` is the resolved caller or
* null. A full inbox is 409 CONFLICT; the oldest message is never dropped.
* - `GET /api/sessions/:id/inbox[?wait=ms]` returns every pending message. Only the
* receiver's own read marks them seen. With `wait`, it parks while nothing is
* UNSEEN (a seen-but-unacked message does not end the wait, or a receiver holding
* one would spin).
* - `POST /api/sessions/:id/inbox/ack` removes messages by id, idempotently, and only
* for the receiver itself (403 otherwise).
* - `GET /api/agent-inbox/summary` reports `pending`, `unseen`, `oldestUnseenAt` and
* `parked` per session the caller can see. Counts only, never text, and kept out of
* `toState()` so a post does not fan out a `session:updated` to every browser.
*
* Nothing here writes to a pane.
*
* @module web/routes/inbox-routes
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { InboxAckSchema, InboxPostSchema, InboxReadQuerySchema } from '../schemas.js';
import {
abortOnClientHangUp,
canAccessOwned,
findSessionOrFail,
getAuthUser,
ownerFor,
parseBody,
resolveParentSessionId,
waitCapacityResponse,
} from '../route-helpers.js';
import { agentInbox, MAX_INBOX_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES_PER_INBOX } from '../agent-inbox.js';
import { sessionWaits, WaitCapacityError, type InboxWaitResult } from '../session-wait-registry.js';
import { clampWaitMs } from '../../config/agent-wait.js';
import type { SessionPort } from '../ports/index.js';
/** Per-session counts the summary route reports. */
export interface InboxSessionSummary {
pending: number;
unseen: number;
oldestUnseenAt: number | null;
/** The receiver itself is parked on `GET …/inbox?wait=` right now. */
parked: boolean;
}
export function registerInboxRoutes(app: FastifyInstance, ctx: SessionPort): void {
/** The resolved caller is the receiving session itself. */
const callerIsReceiver = (req: Parameters<typeof resolveParentSessionId>[1], id: string, owner?: string) =>
resolveParentSessionId(ctx, req, undefined, owner) === id;
app.post('/api/sessions/:id/inbox', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { text } = parseBody(InboxPostSchema, req.body, 'Invalid inbox message');
const from = resolveParentSessionId(ctx, req, undefined, session.owner) ?? null;
const result = agentInbox.post(id, from, text);
if (!result.ok) {
switch (result.reason) {
case 'empty':
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'text must not be empty');
case 'message-too-large':
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Message is larger than ${MAX_MESSAGE_BYTES} bytes (UTF-8). Write it to a file in the workspace and post the path.`
);
case 'inbox-full-count':
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Inbox of ${id} is full (${MAX_MESSAGES_PER_INBOX} messages). Nothing was stored; the receiver has to ack before more can be posted.`
);
case 'inbox-full-bytes':
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Inbox of ${id} is full (${MAX_INBOX_BYTES} bytes of pending text). Nothing was stored; the receiver has to ack before more can be posted.`
);
}
}
// After the store, so a parked reader that wakes finds the message.
sessionWaits.notifyInbox(id);
return {
success: true,
data: { sessionId: id, message: result.message, pending: result.pending, unseen: result.unseen },
};
});
app.get('/api/sessions/:id/inbox', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const query = parseBody(InboxReadQuerySchema, req.query);
const receiver = callerIsReceiver(req, id, session.owner);
// A receiver loops on this URL with identical parameters; a cached 200 would turn
// the loop into a busy spin (same reason as GET …/wait).
reply.header('Cache-Control', 'no-store');
let wait: InboxWaitResult | undefined;
if (query.wait !== undefined) {
const timeoutMs = clampWaitMs(query.wait);
if (agentInbox.unseenCount(id) > 0) {
wait = {
posted: false,
timedOut: false,
immediate: true,
ended: false,
aborted: false,
waitedMs: 0,
timeoutMs,
};
} else {
const abort = abortOnClientHangUp(reply);
try {
wait = await sessionWaits.waitForInbox(id, {
timeoutMs,
owner: ownerFor(req),
abortSignal: abort.signal,
receiver,
});
} catch (err) {
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
throw err;
}
}
}
// Read AFTER the wait: this is what the caller gets, so it must be current. A wait
// that ended because the session was deleted reads an empty inbox. A client that
// hung up never sees this answer, so its read must not mark anything seen.
const read = agentInbox.read(id, { receiver: receiver && !wait?.aborted });
return { success: true, data: { sessionId: id, receiver, ...read, ...(wait ? { wait } : {}) } };
});
app.post('/api/sessions/:id/inbox/ack', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { ids } = parseBody(InboxAckSchema, req.body);
if (!callerIsReceiver(req, id, session.owner)) {
// A no-op here would let a coordinator believe it cleared a worker's mail, and
// would hide a client that forgot the header. Refusing says both out loud.
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Only ${id} itself can acknowledge its inbox (X-Codeman-Parent-Session must resolve to it). Reading as anyone else is a peek.`
);
}
const removed = agentInbox.ack(id, ids);
return {
success: true,
data: { sessionId: id, removed, pending: agentInbox.pendingCount(id), unseen: agentInbox.unseenCount(id) },
};
});
app.get('/api/agent-inbox/summary', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
const user = getAuthUser(req);
const counts = agentInbox.summary();
const sessions: Record<string, InboxSessionSummary> = {};
for (const session of ctx.sessions.values()) {
if (!canAccessOwned(user, session.owner)) continue;
const c = counts.get(session.id);
const parked = sessionWaits.inboxWaiterCount(session.id, true) > 0;
if (!c && !parked) continue;
sessions[session.id] = {
pending: c?.pending ?? 0,
unseen: c?.unseen ?? 0,
oldestUnseenAt: c?.oldestUnseenAt ?? null,
parked,
};
}
return { success: true, data: { sessions } };
});
}
+1
View File
@@ -12,6 +12,7 @@ export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerApprovalRoutes } from './approval-routes.js';
export { registerInboxRoutes } from './inbox-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
+33 -48
View File
@@ -4,7 +4,8 @@
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
*/
import { FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
import { claudeProjectDirName } from '../../case-rename.js';
import { FastifyInstance, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
import { homedir } from 'node:os';
@@ -115,8 +116,11 @@ import {
sessionCapacityMessage,
SETTINGS_PATH,
validatePathWithinBase,
waitCapacityResponse,
abortOnClientHangUp,
} from '../route-helpers.js';
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
import { agentInbox } from '../agent-inbox.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
@@ -738,23 +742,6 @@ function parseWaitQuery<T>(schema: z.ZodType<T>, query: unknown, label: string):
});
}
/**
* Map a waiter-cap rejection to the code that tells the caller the truth.
*
* The two caps mean different things and warrant different recovery: `session` is
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
* documented as "Session is busy") sent an agent off to a different session to hit
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
* later", and clients and proxies already treat 429 that way.
*/
function waitCapacityResponse(err: WaitCapacityError): ApiResponse<never> {
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
// The registry's message already names the scope and the limit; passing it through
// verbatim keeps the wording in one place.
return createErrorResponse(code, err.message);
}
/**
* The signal a session is ALREADY emitting, corrected for liveness.
*
@@ -931,34 +918,6 @@ export function _paneDeathWatcherCount(): number {
return paneDeathWatchers.size;
}
/**
* An `AbortController` that fires when the CLIENT goes away, and only then.
*
* Freeing an abandoned waiter matters because the documented pattern is a loop of
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
* every iteration until the cap is hit and an innocent session reports busy. Same for
* any proxy that cuts the connection.
*
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
* as soon as the request body has finished streaming, which on a POST is BEFORE the
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
* instantly and silently kills the feature (it survives on GET only because a GET has
* no body to finish). `reply.raw` emits `'close'` both when the response completes
* and when the socket dies, and `writableFinished` is what tells those apart: true
* only if the response actually went out. The guard is load-bearing, not defensive.
*
* `app.inject()` never emits `'close'` at all, so this is only observable over real
* HTTP — which is why the regression test for it binds a port.
*/
function abortOnClientHangUp(reply: FastifyReply): AbortController {
const controller = new AbortController();
reply.raw.on('close', () => {
if (!reply.raw.writableFinished) controller.abort();
});
return controller;
}
/**
* Inject the agent skill into a case on create, surfacing only the REFUSALS.
*
@@ -1723,6 +1682,9 @@ export function registerSessionRoutes(
// below re-looks-up the record by id, so the returned SessionState is unused here.
findPersistedSessionOrFail(ctx.store, id, req);
ctx.store.demoteOrRemoveSession(id);
// A detached session kept its agent inbox (server.ts _doCleanupSession drops it
// only on killMux); retiring the row is the delete, so the mail goes now.
agentInbox.drop(id);
// Mirrors the broadcast at the tail of the live-session cleanup path
// (_doCleanupSession in server.ts) — without it, other open tabs keep
// showing the retired row until their next unrelated fetch.
@@ -5118,6 +5080,7 @@ export function registerSessionRoutes(
if (!stat?.isDirectory()) return out;
const workingDir = await decodeProjectKey(projDir);
const staleCwd = new Map<string, boolean>();
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
for (const entry of entries) {
@@ -5222,13 +5185,35 @@ export function registerSessionRoutes(
worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo,
};
// A case rename moves the project directory but leaves the old path in each transcript's
// cwd. When that cwd is gone and the decoded key is a real folder that encodes back to this
// very key (not the $HOME collapse of #265), the key is the truer answer.
let cwd = git.cwd;
if (cwd !== undefined && cwd !== workingDir) {
let stale = staleCwd.get(cwd);
if (stale === undefined) {
stale =
claudeProjectDirName(workingDir) === projDir &&
(await fs.stat(cwd).then(
() => false,
() => true
)) &&
(await fs.stat(workingDir).then(
(s) => s.isDirectory(),
() => false
));
staleCwd.set(cwd, stale);
}
if (stale) cwd = undefined;
}
out.push({
sessionId,
// The transcript's literal cwd beats decodeProjectKey's stat-walked guess,
// which silently collapses to $HOME once the directory is gone (#265).
// Absent cwd falls back to the old behaviour rather than inventing a path.
workingDir: git.cwd ?? workingDir,
workingDirExact: git.cwd !== undefined,
workingDir: cwd ?? workingDir,
workingDirExact: cwd !== undefined,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
+23 -2
View File
@@ -13,6 +13,7 @@ import { totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn } from 'node:child_process';
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 { isMultiUserMode } from '../../config/multiuser.js';
@@ -64,6 +65,8 @@ import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
import { agentInboxPersistence } from '../agent-inbox-persistence.js';
import { agentInbox } from '../agent-inbox.js';
/**
* Defaults for `POST /api/deepseek/install-profile`.
@@ -801,6 +804,13 @@ export function registerSystemRoutes(
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
// A detached session kept its agent inbox. Its record going is the delete only
// when no tmux session stands behind it: a detached pane the multiplexer still
// lists is re-adopted under the same id at the next boot (restoreMuxSessions,
// with or without a state.json record), and its mail must come back with it.
// Keeping a pane that died while detached errs toward the 12 h TTL and the boot
// prune, the same trade the boot keep set makes.
if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id);
}
return { cleanedSessions: result.count };
});
@@ -1007,8 +1017,15 @@ export function registerSystemRoutes(
// ========== System Stats ==========
app.get('/api/system/stats', async () => {
return getSystemStats();
// GPU (load, memory, clock) and CPU clock readings are opt-in: the browser adds ?gpu=1 and ?cpumhz=1 only while
// the matching header setting is on, so a default install never runs nvidia-smi or reads sysfs.
app.get('/api/system/stats', async (req) => {
const query = req.query as { gpu?: string; cpumhz?: string };
const sensors = await readHostSensors({
gpu: query.gpu === '1',
cpuMhz: query.cpumhz === '1',
});
return { ...getSystemStats(), ...sensors };
});
// ========== Settings ==========
@@ -1129,6 +1146,10 @@ export function registerSystemRoutes(
'Workflow run watcher'
);
// Agent inbox snapshot (default OFF). From `merged` like the toggles above: a
// partial PUT that omits the key must not switch it off and delete the file.
agentInboxPersistence.setEnabled(merged.agentInboxPersistEnabled === true);
// Handle image watcher toggle dynamically
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
+53
View File
@@ -23,6 +23,7 @@ import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { MAX_MESSAGE_BYTES } from './agent-inbox.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
@@ -1537,6 +1538,12 @@ export const SettingsUpdateSchema = z
configBackupKeepCount: z.number().int().min(2).max(500).optional(),
/** Also drop snapshots older than this many days; 0 = no age limit. */
configBackupKeepDays: z.number().int().min(0).max(3650).optional(),
/**
* Agent inbox snapshot on disk (src/web/agent-inbox-persistence.ts). SYNCED, default OFF:
* off means no file is written, and switching it off deletes the snapshot (the in-memory
* inboxes stay). On, pending messages between agents survive a server restart.
*/
agentInboxPersistEnabled: 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
@@ -1836,6 +1843,37 @@ export const SessionWaitOutputQuerySchema = z.object({
timeout: z.coerce.number().int().positive().optional(),
});
// ========== Agent Inbox Routes ==========
/**
* Body for `POST /api/sessions/:id/inbox`. The byte cap (`MAX_MESSAGE_BYTES`) is the
* store's, checked there in UTF-8 bytes; this bound is only the outer fence so a
* multi-megabyte body never reaches it. It is 4× the byte cap in characters (a UTF-8
* character is at most 4 bytes), so every over-size message gets the store's answer,
* which tells the sender what to do instead. Multi-line text is fine: it is stored and
* handed back as data, never typed.
*/
export const InboxPostSchema = z
.object({
text: z
.string()
.min(1, 'text must not be empty')
.max(4 * MAX_MESSAGE_BYTES),
})
.strict();
/** Query for `GET /api/sessions/:id/inbox`. `wait` follows the wait routes' `timeout`: clamped, never a 400 for size. */
export const InboxReadQuerySchema = z.object({
wait: z.coerce.number().int().positive().optional(),
});
/** Body for `POST /api/sessions/:id/inbox/ack`: the ids a read handed out. */
export const InboxAckSchema = z
.object({
ids: z.array(z.string().min(1).max(64)).min(1).max(200),
})
.strict();
// ========== Session Mutation Routes ==========
/** PUT /api/sessions/:id/name */
@@ -1992,6 +2030,21 @@ export const LinkCaseSchema = z.object({
path: safePathSchema,
});
/** POST /api/cases/:name/rename */
export const RenameCaseSchema = z
.object({
newName: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
/** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */
renameFolder: z.boolean().optional(),
/** Stop the live sessions that have the folder open, instead of refusing with the list of them. */
stopSessions: z.boolean().optional(),
})
.strict();
/** PUT /api/cases/order */
export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
+2
View File
@@ -100,6 +100,8 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.codeman[^/]*\/settings\.json$/,
/\/\.codeman[^/]*\/push-keys\.json$/,
/\/\.codeman[^/]*\/intents\.json$/,
// Pending agent-inbox messages (opt-in snapshot): same text as prompts, same 0600.
/\/\.codeman[^/]*\/agent-inbox\.json$/,
];
/**
+59 -2
View File
@@ -45,6 +45,7 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import { stopHostSensors } from '../host-sensors.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
@@ -116,6 +117,8 @@ import {
} from './session-listener-wiring.js';
import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { agentInbox } from './agent-inbox.js';
import { agentInboxPersistence } from './agent-inbox-persistence.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
@@ -201,6 +204,7 @@ import {
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
registerInboxRoutes,
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
@@ -238,6 +242,7 @@ import {
} from './routes/index.js';
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
import { caseSessionSettled } from '../case-rename.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -457,6 +462,7 @@ export class WebServer extends EventEmitter {
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private userIpFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
@@ -801,6 +807,8 @@ export class WebServer extends EventEmitter {
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
this.sessions.set(session.id, session);
// In the map now, so a case rename lists (and on request stops) it instead of refusing the move.
caseSessionSettled(session);
return () => {
if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
};
@@ -959,6 +967,7 @@ export class WebServer extends EventEmitter {
this.qrAuthFailures = authState.qrAuthFailures;
this.hookSecretFailures = authState.hookSecretFailures;
this.userFailures = authState.userFailures;
this.userIpFailures = authState.userIpFailures;
}
// WebSocket support (terminal I/O — low-latency bidirectional channel)
@@ -1229,6 +1238,7 @@ export class WebServer extends EventEmitter {
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);
registerInboxRoutes(this.app, ctx);
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
@@ -1686,6 +1696,11 @@ export class WebServer extends EventEmitter {
// The 'exit' here is NOT redundant with the PTY-exit listener: listeners are
// detached a few lines above, before `session.stop()`, so on a delete the
// session's own exit event never reaches the registry.
// The agent inbox goes with a DELETED session only. killMux=false is shutdown or
// detach: mail posted before that is still there when the session is re-attached
// (while detached, the routes answer 404, so nothing new arrives). Before
// cancelAll, so a reader released by it reads an empty inbox.
if (killMux) agentInbox.drop(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
@@ -2211,6 +2226,13 @@ export class WebServer extends EventEmitter {
return settings.agentSkillEnabled === true;
}
// Whether pending agent-inbox mail is snapshotted to disk (synced
// `agentInboxPersistEnabled` setting). Default OFF: an absent key writes nothing.
private async getAgentInboxPersistEnabled(): Promise<boolean> {
const settings = await this.readSettings();
return settings.agentInboxPersistEnabled === true;
}
// Whether a Claude session installs Codeman's hooks block into its workspace
// (synced `workspaceHooksEnabled` setting). Default ON — an absent key means a
// user who has never seen this setting, and OFF for them would mean no tab
@@ -3041,6 +3063,10 @@ export class WebServer extends EventEmitter {
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) {
// Pending agent mail first (opt-in snapshot), so it is in place before any
// route can answer. Pruned against the restored sessions in finalizeRestoredState.
const restoredMail = agentInboxPersistence.load(await this.getAgentInboxPersistEnabled());
if (restoredMail > 0) console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`);
const restored = await this.restoreMuxSessions();
await this.finalizeRestoredState(restored);
@@ -3934,6 +3960,7 @@ export class WebServer extends EventEmitter {
}
this.sessions.set(session.id, session);
caseSessionSettled(session);
await this.setupSessionListeners(session);
// Auto-attach PTY to the surviving tmux session immediately.
@@ -4013,6 +4040,20 @@ export class WebServer extends EventEmitter {
this.tabLayouts.markRestorationComplete();
await this.cleanupStaleSessions();
await this.tabLayouts.reconcileAfterRestoration();
// Same gate as the tab layouts: only a restore that succeeded may decide which
// inboxes are orphans. A failed one returns above and leaves every message alone.
// Kept: live sessions plus every record state.json still holds (a session that
// was not re-adopted this boot but is kept for recovery keeps its mail), plus the
// sessions on the reboot-restore offer. `cleanupStaleSessions()` above has already
// pruned their records, and a rebuild reuses the old id, so without them a restored
// session would come back to an empty inbox. The 12 h TTL bounds what is never rebuilt.
const keep = new Set([
...this.sessions.keys(),
...Object.keys(this.store.getSessions()),
...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId),
]);
const pruned = agentInbox.pruneExcept(keep);
if (pruned > 0) console.log(`[AgentInbox] dropped ${pruned} inbox(es) of sessions that did not come back`);
}
/**
@@ -4173,6 +4214,9 @@ export class WebServer extends EventEmitter {
// got wrong once.
void stopDeepSeekWeb();
// The header's GPU / CPU clock sampler is a Windows child process under WSL, and its idle sweep a timer.
stopHostSensors();
// Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed
// only by the periodic idle sweep, whose interval is disposed just below.
closeAllLlamaSwapLogTails();
@@ -4189,8 +4233,11 @@ export class WebServer extends EventEmitter {
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.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();
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
// and `app.close()` (the last line of this method) does not abort in-flight requests —
// and `app.close()` (at the end of this method) does not abort in-flight requests,
// so without this a restart during a wake waits out the readiness poll.
this.remoteWake?.stop();
@@ -4324,6 +4371,10 @@ export class WebServer extends EventEmitter {
this.userFailures.dispose();
this.userFailures = null;
}
if (this.userIpFailures) {
this.userIpFailures.dispose();
this.userIpFailures = null;
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
this.killingSessions.clear();
@@ -4331,7 +4382,13 @@ export class WebServer extends EventEmitter {
// Dispose push store (flush pending saves)
this.pushStore.dispose();
await this.app.close();
try {
await this.app.close();
} finally {
// The routes answer until `app.close()` returns, so a post after the flush above
// would otherwise sit in an unref'd debounce timer and never reach disk.
agentInboxPersistence.flush();
}
}
}
+163 -7
View File
@@ -7,11 +7,16 @@
* lets a request *block* until something happens instead, so an orchestrating
* agent can `curl` and wait. Plan: `docs/agent-control-plan.md`.
*
* Two waiter kinds:
* Three waiter kinds:
* - **signal waiters** resolve on the first of a requested set of lifecycle
* signals (`idle`, `working`, `stop`, `blocked`, `exit`).
* - **output waiters** resolve when a literal string appears in a session's
* ANSI-stripped output.
* - **inbox waiters** resolve when a message is posted to the session's agent
* inbox (`agent-inbox.ts`). The registry knows nothing about messages: the
* route decides whether to park (nothing unseen) and `notifyInbox()` is the
* post's "something arrived". Kept here so `GET .../inbox?wait=` is charged to
* the same per-session / per-owner / total caps as the other two.
*
* ## What "ANSI-stripped output" means here
*
@@ -390,6 +395,12 @@ export interface OutputWaitResult extends WaitResultBase {
snippet: string | null;
}
/** Outcome of an inbox wait. */
export interface InboxWaitResult extends WaitResultBase {
/** True when a post released the wait (the caller should read the inbox now). */
posted: boolean;
}
/**
* Thrown when a waiter would exceed a configured cap.
*
@@ -458,6 +469,15 @@ export interface OutputWaitOptions extends WaitOptionsBase {
initialText?: string;
}
export interface InboxWaitOptions extends WaitOptionsBase {
/**
* The caller IS the receiving session (as resolved by the route), not a coordinator
* peeking. Counted separately so the inbox summary's `parked` means "the receiver is
* listening", which is what a sender deciding whether to nudge needs to know.
*/
receiver?: boolean;
}
export interface SessionWaitRegistryOptions {
maxWaitersPerSession?: number;
maxWaitersPerOwner?: number;
@@ -483,6 +503,11 @@ interface SignalWaiter extends WaiterBase {
settle: (result: SignalWaitResult) => void;
}
interface InboxWaiter extends WaiterBase {
receiver: boolean;
settle: (result: InboxWaitResult) => void;
}
interface OutputWaiter extends WaiterBase {
/** Original-case needle, kept for reporting. */
needle: string;
@@ -505,6 +530,7 @@ interface OutputWaiter extends WaiterBase {
export class SessionWaitRegistry {
private readonly signalWaiters = new Map<string, Set<SignalWaiter>>();
private readonly outputWaiters = new Map<string, Set<OutputWaiter>>();
private readonly inboxWaiters = new Map<string, Set<InboxWaiter>>();
/**
* Live waiter count per owner. A derived count would mean walking every waiter in
* the process on each registration, so it is maintained incrementally instead:
@@ -544,20 +570,34 @@ export class SessionWaitRegistry {
return this.outputWaiters.get(sessionId)?.size ?? 0;
}
/** Pending waiters of both kinds for a session. */
waiterCount(sessionId: string): number {
return this.signalWaiterCount(sessionId) + this.outputWaiterCount(sessionId);
/**
* Pending inbox waiters for a session; with `receiverOnly`, only those whose caller
* is the receiving session itself (the inbox summary's `parked`).
*/
inboxWaiterCount(sessionId: string, receiverOnly = false): number {
const set = this.inboxWaiters.get(sessionId);
if (!set) return 0;
if (!receiverOnly) return set.size;
let count = 0;
for (const waiter of set) if (waiter.receiver) count++;
return count;
}
/** Pending waiters of both kinds across every session. */
/** Pending waiters of every kind for a session. */
waiterCount(sessionId: string): number {
return this.signalWaiterCount(sessionId) + this.outputWaiterCount(sessionId) + this.inboxWaiterCount(sessionId);
}
/** Pending waiters of every kind across every session. */
totalWaiterCount(): number {
let total = 0;
for (const set of this.signalWaiters.values()) total += set.size;
for (const set of this.outputWaiters.values()) total += set.size;
for (const set of this.inboxWaiters.values()) total += set.size;
return total;
}
/** Pending waiters of both kinds charged to one owner. */
/** Pending waiters of every kind charged to one owner. */
ownerWaiterCount(owner: string): number {
return this.ownerCounts.get(owner) ?? 0;
}
@@ -920,6 +960,105 @@ export class SessionWaitRegistry {
}
}
// ── Inbox waits ──
/**
* Block until a message is posted to this session's inbox, the timeout elapses, or
* the session ends.
*
* There is no "already satisfied" shortcut here: whether the inbox holds unseen mail
* is the store's business, so the route checks that first and only calls this when
* there is nothing to hand out. A post that lands between that check and this call
* is not lost either: both run in the same tick, and `notifyInbox` is called by the
* post route after the message is stored.
*
* @throws {WaitCapacityError} when a waiter cap is already reached.
*/
waitForInbox(sessionId: string, options: InboxWaitOptions): Promise<InboxWaitResult> {
const base = { posted: false, timedOut: false, immediate: false, waitedMs: 0, timeoutMs: options.timeoutMs };
if (options.abortSignal?.aborted) return Promise.resolve({ ...base, ended: true, aborted: true });
if (this.stopped) return Promise.resolve({ ...base, ended: true, aborted: false });
this.assertCapacity(sessionId, options.owner);
return new Promise<InboxWaitResult>((resolve) => {
let set = this.inboxWaiters.get(sessionId);
if (!set) {
set = new Set<InboxWaiter>();
this.inboxWaiters.set(sessionId, set);
}
const waiter: InboxWaiter = {
receiver: options.receiver === true,
startedAt: Date.now(),
timeoutMs: options.timeoutMs,
owner: options.owner,
timer: setTimeout(() => {
this.removeInboxWaiter(sessionId, waiter);
resolve({
posted: false,
timedOut: true,
immediate: false,
ended: false,
aborted: false,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
}, options.timeoutMs),
settle: resolve,
};
set.add(waiter);
this.chargeOwner(options.owner);
waiter.detachAbort = this.attachAbort(options.abortSignal, () => {
this.removeInboxWaiter(sessionId, waiter);
resolve({
posted: false,
timedOut: false,
immediate: false,
ended: true,
aborted: true,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
});
});
}
/**
* A message was posted to this session's inbox: release every inbox waiter on it.
*
* @returns how many waiters were resolved.
*/
notifyInbox(sessionId: string): number {
const set = this.inboxWaiters.get(sessionId);
if (!set || set.size === 0) return 0;
let resolved = 0;
for (const waiter of [...set]) {
this.removeInboxWaiter(sessionId, waiter);
waiter.settle({
posted: true,
timedOut: false,
immediate: false,
ended: false,
aborted: false,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
resolved++;
}
return resolved;
}
private removeInboxWaiter(sessionId: string, waiter: InboxWaiter): void {
clearTimeout(waiter.timer);
waiter.detachAbort?.();
const set = this.inboxWaiters.get(sessionId);
if (!set) return;
if (!set.delete(waiter)) return;
this.releaseOwner(waiter.owner);
if (set.size === 0) this.inboxWaiters.delete(sessionId);
}
// ── Teardown ──
/**
@@ -969,6 +1108,23 @@ export class SessionWaitRegistry {
}
}
const inbox = this.inboxWaiters.get(sessionId);
if (inbox) {
for (const waiter of [...inbox]) {
this.removeInboxWaiter(sessionId, waiter);
waiter.settle({
posted: false,
timedOut: false,
immediate: false,
ended: true,
aborted: false,
waitedMs: Date.now() - waiter.startedAt,
timeoutMs: waiter.timeoutMs,
});
resolved++;
}
}
this.pendingAnsi.delete(sessionId);
return resolved;
}
@@ -999,7 +1155,7 @@ export class SessionWaitRegistry {
cancelEverything(): number {
this.stopped = true;
let resolved = 0;
const ids = new Set([...this.signalWaiters.keys(), ...this.outputWaiters.keys()]);
const ids = new Set([...this.signalWaiters.keys(), ...this.outputWaiters.keys(), ...this.inboxWaiters.keys()]);
for (const id of ids) resolved += this.cancelAll(id);
return resolved;
}
+4 -1
View File
@@ -32,7 +32,7 @@
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
* - **Cases** (4): created, linked, deleted, order-changed
* - **Cases** (5): created, linked, deleted, renamed, order-changed
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
* - **Web tabs** (2): webview:changed, tab:layoutChanged
@@ -458,6 +458,8 @@ export const CaseCreated = 'case:created' as const;
export const CaseLinked = 'case:linked' as const;
/** Case deleted or unlinked. */
export const CaseDeleted = 'case:deleted' as const;
/** Case renamed (name and folder). */
export const CaseRenamed = 'case:renamed' as const;
/** Case ordering changed. */
export const CaseOrderChanged = 'case:order-changed' as const;
@@ -702,6 +704,7 @@ export const SseEvent = {
CaseCreated,
CaseLinked,
CaseDeleted,
CaseRenamed,
CaseOrderChanged,
// Docker cases
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Opt-in agent inbox snapshot (src/web/agent-inbox-persistence.ts).
*
* Off writes nothing, switching off deletes the file and keeps memory, on writes 0600
* through tmp + rename, and a boot with the setting on restores what was pending.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { AgentInbox } from '../src/web/agent-inbox.js';
import { AgentInboxPersistence } from '../src/web/agent-inbox-persistence.js';
describe('AgentInboxPersistence', () => {
let dir: string;
let file: string;
let inbox: AgentInbox;
let persistence: AgentInboxPersistence;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-inbox-'));
file = join(dir, 'agent-inbox.json');
inbox = new AgentInbox();
// Debounce 0 so a test can wait one macrotask instead of sleeping.
persistence = new AgentInboxPersistence(inbox, () => file, 0);
});
afterEach(() => {
persistence.dispose();
rmSync(dir, { recursive: true, force: true });
});
const tick = () => new Promise((r) => setTimeout(r, 5));
it('writes nothing while off (the default)', async () => {
persistence.load(false);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(false);
});
it('writes a 0600 snapshot when on, and no tmp file is left behind', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s[0].text).toBe('hello');
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(existsSync(`${file}.tmp`)).toBe(false);
});
it('switching on writes the current inboxes at once', () => {
persistence.load(false);
inbox.post('s', null, 'posted while off');
persistence.setEnabled(true);
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
});
it('switching off deletes the snapshot and keeps the in-memory inboxes', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(true);
persistence.setEnabled(false);
expect(existsSync(file)).toBe(false);
expect(inbox.pendingCount('s')).toBe(1);
inbox.post('s', null, 'after off');
await tick();
expect(existsSync(file)).toBe(false);
});
it('a boot with the setting on restores pending mail', async () => {
persistence.load(true);
inbox.post('s', 'sender', 'survives');
await tick();
persistence.dispose();
const next = new AgentInbox();
const nextPersistence = new AgentInboxPersistence(next, () => file, 0);
expect(nextPersistence.load(true)).toBe(1);
expect(next.read('s', { receiver: false }).messages[0]).toMatchObject({ from: 'sender', text: 'survives' });
nextPersistence.dispose();
});
it('a boot with the setting off removes a file left from earlier and restores nothing', () => {
writeFileSync(file, JSON.stringify({ version: 1, inboxes: { s: [] } }));
expect(persistence.load(false)).toBe(0);
expect(existsSync(file)).toBe(false);
});
it('an unreadable file is an empty inbox, not a failed boot', () => {
writeFileSync(file, '{not json');
expect(persistence.load(true)).toBe(0);
});
it('flush writes a pending change before shutdown', () => {
const slow = new AgentInboxPersistence(inbox, () => file, 60_000);
slow.load(true);
inbox.post('s', null, 'just before shutdown');
expect(existsSync(file)).toBe(false);
slow.flush();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
slow.dispose();
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* @fileoverview How server.ts treats the agent inbox across a session's lifecycle.
*
* - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`)
* keep it, because a detached session comes back.
* - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps
* every session state.json still holds plus every session on the reboot-restore
* offer (its record is already pruned, and a rebuild reuses the id). A failed
* restore leaves all mail alone.
*
* Uses a bare `WebServer.prototype` object with just the fields these paths touch,
* like tab-layout-restore-gate.test.ts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { agentInbox } from '../src/web/agent-inbox.js';
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
import type { SessionState } from '../src/types.js';
type CleanupServer = {
_doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void>;
finalizeRestoredState(restored: boolean): Promise<void>;
[key: string]: unknown;
};
function bareServer(live: string[] = [], persisted: string[] = []): CleanupServer {
const server = Object.create(WebServer.prototype) as CleanupServer;
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
removeRalphState: () => {},
},
tabLayouts: {
markRestorationComplete: () => {},
markRestorationFailed: () => {},
reconcileAfterRestoration: async () => {},
},
cleanupStaleSessions: async () => 0,
_customModelDisplacedNotified: new Set(),
respawnControllers: new Map(),
respawnTimers: new Map(),
pendingRespawnStarts: new Map(),
runSummaryTrackers: new Map(),
stopTranscriptWatcher: () => {},
persistDeb: { cancelKey: () => {} },
sse: { cleanupSessionBatches: () => {} },
broadcast: () => {},
});
return server;
}
/** A reboot-restore offer entry; the prune reads only its `sessionId`. */
function offerEntry(sessionId: string): RebootRestoreEntry {
return {
sessionId,
workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}`,
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: { id: sessionId, workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}` } as SessionState,
};
}
describe('agent inbox server wiring', () => {
beforeEach(() => agentInbox.resetForTests());
afterEach(() => {
agentInbox.resetForTests();
rebootRestoreRegistry.reset();
});
it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => {
const server = bareServer();
agentInbox.post('detached', null, 'still here after detach');
agentInbox.post('deleted', null, 'gone with the session');
await server._doCleanupSession('detached', false, 'test');
await server._doCleanupSession('deleted', true, 'test');
expect(agentInbox.pendingCount('detached')).toBe(1);
expect(agentInbox.pendingCount('deleted')).toBe(0);
});
it('a failed restore prunes nothing', async () => {
const server = bareServer();
agentInbox.post('not-restored', null, 'm');
await server.finalizeRestoredState(false);
expect(agentInbox.pendingCount('not-restored')).toBe(1);
});
it('a successful restore prunes orphans and keeps live and persisted sessions', async () => {
const server = bareServer(['live'], ['kept-for-recovery']);
for (const id of ['live', 'kept-for-recovery', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(agentInbox.pendingCount('live')).toBe(1);
expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
it('a successful restore keeps the inbox of a session on the reboot-restore offer', async () => {
// After a host reboot the dead session is in neither `this.sessions` nor state.json
// (cleanupStaleSessions already pruned its record), only in the offer, and the
// rebuild reuses its id, so its mail must still be there when it comes back.
const server = bareServer();
rebootRestoreRegistry.set([offerEntry('on-offer')]);
for (const id of ['on-offer', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['on-offer']);
expect(agentInbox.pendingCount('on-offer')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
});
+279
View File
@@ -0,0 +1,279 @@
/**
* @fileoverview The agent inbox store (src/web/agent-inbox.ts), pure: no routes, no IO.
*
* Each block pins one rule from discussion #558: read until ack, seen only for the
* receiver, byte and count caps that refuse instead of dropping, a TTL counted from
* each message's own timestamp, and a snapshot that survives malformed input.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
AgentInbox,
MAX_INBOX_BYTES,
MAX_MESSAGE_BYTES,
MAX_MESSAGES_PER_INBOX,
MESSAGE_TTL_MS,
} from '../src/web/agent-inbox.js';
const RX = 'receiver-session';
describe('AgentInbox', () => {
let now: number;
let inbox: AgentInbox;
beforeEach(() => {
now = 1_000_000;
inbox = new AgentInbox(() => now);
});
const post = (text: string, from: string | null = 'sender') => {
const r = inbox.post(RX, from, text);
if (!r.ok) throw new Error(`post refused: ${r.reason}`);
return r.message;
};
describe('read until ack', () => {
it('a read removes nothing; the same messages come back until acked', () => {
const a = post('first');
const b = post('second');
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.pendingCount(RX)).toBe(2);
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([b.id]);
});
it('ack is idempotent: an id that is already gone or never existed is not an error', () => {
const a = post('x');
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.ack(RX, [a.id])).toBe(0);
expect(inbox.ack(RX, ['no-such-id'])).toBe(0);
expect(inbox.ack('no-such-session', [a.id])).toBe(0);
});
it('ack removes only the given ids, never a message posted after the read', () => {
const a = post('read me');
inbox.read(RX, { receiver: true });
const late = post('arrived after the read');
inbox.ack(RX, [a.id]);
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('keeps multi-line text byte for byte', () => {
const text = 'line one\nline two\r\n\ttabbed\n';
post(text);
expect(inbox.read(RX, { receiver: true }).messages[0].text).toBe(text);
});
});
describe('seen belongs to the receiver', () => {
it('a receiver read marks seen; the flag on each message is the state BEFORE this read', () => {
post('m');
const first = inbox.read(RX, { receiver: true });
expect(first.messages[0].seen).toBe(false);
expect(first.unseen).toBe(0);
const second = inbox.read(RX, { receiver: true });
expect(second.messages[0].seen).toBe(true);
});
it('a peek changes nothing: unseen stays unseen', () => {
post('m');
const peek = inbox.read(RX, { receiver: false });
expect(peek.messages[0].seen).toBe(false);
expect(peek.unseen).toBe(1);
expect(inbox.unseenCount(RX)).toBe(1);
expect(inbox.summary().get(RX)).toMatchObject({ pending: 1, unseen: 1 });
});
it('a message posted after a receiver read is unseen again', () => {
post('a');
inbox.read(RX, { receiver: true });
expect(inbox.unseenCount(RX)).toBe(0);
post('b');
expect(inbox.unseenCount(RX)).toBe(1);
});
});
describe('caps refuse, never drop the oldest', () => {
it(`refuses message ${MAX_MESSAGES_PER_INBOX + 1} and keeps the first one`, () => {
const first = post('0');
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) post(String(i));
expect(inbox.post(RX, null, 'one too many')).toEqual({ ok: false, reason: 'inbox-full-count' });
const ids = inbox.read(RX, { receiver: false }).messages.map((m) => m.id);
expect(ids).toHaveLength(MAX_MESSAGES_PER_INBOX);
expect(ids[0]).toBe(first.id);
});
it('counts the per-message cap in UTF-8 bytes, not characters', () => {
// 'é' is 2 bytes: half the byte budget in characters already fills it.
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2)).ok).toBe(true);
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2 + 1))).toEqual({
ok: false,
reason: 'message-too-large',
});
});
it('refuses a post that would cross the per-inbox byte cap', () => {
const chunk = 'x'.repeat(MAX_MESSAGE_BYTES);
const fit = Math.floor(MAX_INBOX_BYTES / MAX_MESSAGE_BYTES);
for (let i = 0; i < fit; i++) post(chunk);
expect(inbox.post(RX, null, 'x'.repeat(MAX_INBOX_BYTES - fit * MAX_MESSAGE_BYTES + 1))).toEqual({
ok: false,
reason: 'inbox-full-bytes',
});
expect(inbox.pendingCount(RX)).toBe(fit);
});
it('an ack makes room again', () => {
const ids: string[] = [];
for (let i = 0; i < MAX_MESSAGES_PER_INBOX; i++) ids.push(post(String(i)).id);
inbox.ack(RX, [ids[0]]);
expect(inbox.post(RX, null, 'now it fits').ok).toBe(true);
});
it('refuses empty text', () => {
expect(inbox.post(RX, null, '')).toEqual({ ok: false, reason: 'empty' });
});
});
describe('TTL from the message timestamp', () => {
it('a message older than the TTL is gone, read or not', () => {
post('old');
now += MESSAGE_TTL_MS / 2;
const fresh = post('fresh');
now += MESSAGE_TTL_MS / 2 + 1;
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([fresh.id]);
});
it('a restore keeps createdAt, so a restart does not restart the clock', () => {
post('m');
const snap = JSON.parse(JSON.stringify(inbox.snapshot()));
now += MESSAGE_TTL_MS - 1000;
const restarted = new AgentInbox(() => now);
expect(restarted.restore(snap)).toBe(1);
now += 1001;
expect(restarted.pendingCount(RX)).toBe(0);
});
it('does not restore a message that expired while the server was down', () => {
post('m');
const snap = inbox.snapshot();
now += MESSAGE_TTL_MS + 1;
expect(new AgentInbox(() => now).restore(snap)).toBe(0);
});
it('sweeps an expired message that is not first (clock stepped back)', () => {
const late = post('posted first, stamped later');
now -= 1000; // the clock steps back
const early = post('posted second, stamped earlier');
now += MESSAGE_TTL_MS + 1000 - (late.createdAt - early.createdAt) / 2;
// `early` is past the TTL, `late` is not, and `early` sits behind `late`.
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('expiry fires onChange so the snapshot catches up', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
now += MESSAGE_TTL_MS + 1;
inbox.summary();
expect(changes).toBe(1);
});
});
describe('summary', () => {
it('reports counts and the oldest unseen time, never text', () => {
const a = post('secret one');
now += 10;
post('secret two');
inbox.read(RX, { receiver: true });
now += 10;
const c = post('secret three');
const row = inbox.summary().get(RX);
expect(row).toEqual({ pending: 3, unseen: 1, oldestUnseenAt: c.createdAt });
expect(JSON.stringify(row)).not.toContain('secret');
expect(a.createdAt).toBeLessThan(c.createdAt);
});
it('leaves out sessions with an empty inbox', () => {
const a = post('m');
inbox.ack(RX, [a.id]);
expect(inbox.summary().has(RX)).toBe(false);
});
});
describe('lifecycle', () => {
it('drop discards the inbox and fires onChange', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
inbox.drop(RX);
expect(inbox.pendingCount(RX)).toBe(0);
expect(changes).toBe(1);
});
it('pruneExcept drops only inboxes of sessions not kept', () => {
inbox.post('keep-me', null, 'a');
inbox.post('gone', null, 'b');
expect(inbox.pruneExcept(['keep-me'])).toBe(1);
expect(inbox.pendingCount('keep-me')).toBe(1);
expect(inbox.pendingCount('gone')).toBe(0);
});
});
describe('snapshot / restore', () => {
it('round-trips messages, senders and the seen state', () => {
const a = post('from a session', 'sender-1');
post('from the user', null);
inbox.read(RX, { receiver: true });
const c = post('unseen');
const restored = new AgentInbox(() => now);
expect(restored.restore(JSON.parse(JSON.stringify(inbox.snapshot())))).toBe(3);
const read = restored.read(RX, { receiver: false });
expect(read.messages.map((m) => [m.id, m.from, m.seen])).toEqual([
[a.id, 'sender-1', true],
[expect.any(String), null, true],
[c.id, 'sender', false],
]);
});
it('skips malformed entries and files instead of throwing', () => {
const restored = new AgentInbox(() => now);
expect(restored.restore(null)).toBe(0);
expect(restored.restore({ version: 2, inboxes: {} })).toBe(0);
expect(restored.restore({ version: 1, inboxes: { s: 'nope' } })).toBe(0);
const good = { id: 'g', from: null, text: 'ok', createdAt: now, seenAt: null };
expect(
restored.restore({
version: 1,
inboxes: {
s: [
good,
{ id: 1 },
{ ...good, id: 'big', text: 'x'.repeat(MAX_MESSAGE_BYTES + 1) },
{ ...good, text: '' },
],
},
})
).toBe(1);
});
it('restores oldest first whatever the file order, so the caps keep the oldest', () => {
const msg = (id: string, createdAt: number) => ({ id, from: null, text: 'x', createdAt, seenAt: null });
const restored = new AgentInbox(() => now);
restored.restore({ version: 1, inboxes: { s: [msg('new', now - 10), msg('old', now - 20)] } });
expect(restored.read('s', { receiver: false }).messages.map((m) => m.id)).toEqual(['old', 'new']);
});
it('holds the count cap on restore', () => {
const many = Array.from({ length: MAX_MESSAGES_PER_INBOX + 5 }, (_, i) => ({
id: `m${i}`,
from: null,
text: 'x',
createdAt: now,
seenAt: null,
}));
const restored = new AgentInbox(() => now);
expect(restored.restore({ version: 1, inboxes: { s: many } })).toBe(MAX_MESSAGES_PER_INBOX);
});
});
});
+2 -1
View File
@@ -15,6 +15,7 @@ import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
@@ -46,7 +47,7 @@ async function getSessionCookie(baseUrl: string): Promise<string> {
}
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
for (let i = 0; i < 10; i++) {
for (let i = 0; i < AUTH_FAILURE_MAX; i++) {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
});
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview A case rename racing a session create, against the real WebServer.
*
* A create constructs its `Session` (which checks the rename lock) and only puts it in the server's session
* map later, after awaiting the owner's layout lock and a settings read. A rename that ran in that gap built its
* list of open sessions without the new one, moved the folder, and the create then registered a session whose
* pane would `cd` into a path that no longer existed. The rename now refuses the move while such a session is
* still being created, and the server's registration is what lets go of it.
*
* The gap is widened by holding the settings read inside `TabLayoutService.sessionCreated()`
* (`childrenFollowParent`), the await between the constructor and the registration callback.
*
* Port: 0 (ephemeral, read back from `server.boundPort`).
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { isCaseSessionStarting } from '../src/case-rename.js';
import { safeRmHomeTree } from './mocks/test-helpers.js';
interface LayoutDeps {
childrenFollowParent?: () => Promise<boolean>;
}
let server: WebServer;
const made: string[] = [];
const url = (path: string) => `http://localhost:${server.boundPort}${path}`;
const post = async (path: string, body: unknown) => {
const res = await fetch(url(path), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, json: (await res.json()) as Record<string, any> };
};
const localCase = (name: string) => {
const dir = join(homedir(), 'codeman-cases', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'CLAUDE.md'), name);
made.push(dir);
return dir;
};
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
}, 60000);
afterAll(async () => {
await server?.stop();
for (const dir of made.splice(0)) safeRmHomeTree(dir);
}, 60000);
describe('renaming a case while a session is being created in it', () => {
it('refuses the move until the session is registered, then lists it like any open session', async () => {
const dir = localCase('race-alpha');
made.push(join(homedir(), 'codeman-cases', 'race-beta')); // only there if a rename got through
const deps = (server as unknown as { tabLayouts: { deps: LayoutDeps } }).tabLayouts.deps;
const original = deps.childrenFollowParent;
let reached!: () => void;
const atGap = new Promise<void>((resolve) => (reached = resolve));
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
deps.childrenFollowParent = async () => {
reached();
await gate;
return (await original?.()) === true;
};
try {
const create = post('/api/sessions', { workingDir: dir, mode: 'shell' });
await atGap; // constructed, not yet in the server's session map
expect(isCaseSessionStarting(dir)).toBe(true);
// Without the refusal the rename moves the folder and then waits on the owner's layout lock, which the
// held create owns: bound the wait so that shows up as a failure rather than a test timeout.
const busy = await Promise.race([
post('/api/cases/race-alpha/rename', { newName: 'race-beta', stopSessions: true }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
]);
expect(existsSync(dir), 'the folder moved under the session being created').toBe(true);
expect(busy?.status).toBe(409);
expect(busy?.json).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
deps.childrenFollowParent = original;
release();
const created = await create;
expect(created.status).toBe(200);
const id = created.json.data.session.id as string;
expect(isCaseSessionStarting(dir)).toBe(false);
const listed = await post('/api/cases/race-alpha/rename', { newName: 'race-beta' });
expect(listed.status).toBe(409);
expect(listed.json).toMatchObject({ requiresStop: true, sessions: [{ id }] });
expect(existsSync(dir)).toBe(true);
await fetch(url(`/api/sessions/${id}`), { method: 'DELETE' });
} finally {
deps.childrenFollowParent = original;
release();
}
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* @fileoverview The Rename dialog's flow in session-ui.js (renameCase / submitRenameCase): it is the app's
* own modal, not a browser prompt; a linked case offers the folder checkbox; live sessions are listed inside
* the dialog and only stopped after a second press; server errors stay in the dialog. Evaluates the real file
* against a stub DOM.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
const HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
type El = {
value: string;
textContent: string;
checked: boolean;
disabled: boolean;
classes: Set<string>;
classList: {
add(c: string): void;
remove(c: string): void;
toggle(c: string, on?: boolean): void;
contains(c: string): boolean;
};
focus(): void;
select(): void;
};
const el = (hidden = false): El => {
const classes = new Set<string>(hidden ? ['hidden'] : []);
return {
value: '',
textContent: '',
checked: false,
disabled: false,
classes,
classList: {
add: (c) => void classes.add(c),
remove: (c) => void classes.delete(c),
toggle: (c, on) => void ((on ?? !classes.has(c)) ? classes.add(c) : classes.delete(c)),
contains: (c) => classes.has(c),
},
focus() {},
select() {},
};
};
function makeApp(opts: { responses: unknown[]; cases: unknown[]; current?: string }) {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const toasts: string[] = [];
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
const queue = [...opts.responses];
const ids = [
'renameCaseModal',
'renameCaseInput',
'renameCaseLabel',
'renameCaseFolderRow',
'renameCaseFolder',
'renameCaseFolderHint',
'renameCaseNote',
'renameCaseError',
'renameCaseSubmit',
'quickStartCase',
];
const els: Record<string, El> = Object.fromEntries(ids.map((id) => [id, el()]));
els.quickStartCase.value = opts.current ?? 'alpha';
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => els[id] ?? null },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
fetch: async (url: string, init: { body: string }) => {
calls.push({ url, body: JSON.parse(init.body) });
return { json: async () => queue.shift() };
},
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
const refreshed: unknown[] = [];
Object.assign(app, {
cases: opts.cases,
showToast: (m: string) => void toasts.push(m),
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
renderCaseManageList: () => void refreshed.push('render'),
});
return { app, els, calls, toasts, store, refreshed };
}
const ok = { success: true, data: {} };
describe('the Rename dialog is part of the page, not a browser prompt', () => {
it('has its modal in index.html, with the name, folder and error elements the code drives', () => {
for (const id of [
'renameCaseModal',
'renameCaseInput',
'renameCaseFolder',
'renameCaseError',
'renameCaseSubmit',
]) {
expect(HTML).toContain(`id="${id}"`);
}
const fn = SRC.slice(SRC.indexOf('renameCase(name)'), SRC.indexOf('async deleteCase(name)'));
expect(fn).not.toMatch(/\b(prompt|confirm|alert)\(/);
});
});
describe('renameCase / submitRenameCase', () => {
it('opens pre-filled, renames a local case, moves its run options and reselects it under the new name', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', location: 'local' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseInput.value).toBe('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(true);
t.els.renameCaseInput.value = ' beta ';
await t.app.submitRenameCase();
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
expect(t.store.has('caseSettings_alpha')).toBe(false);
expect(t.refreshed).toEqual(['beta', 'render']);
});
it('keeps the dialog open with a message for the same name or a name that is not allowed, and sends nothing', async () => {
for (const entered of ['', 'alpha', 'a b', '../x']) {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = entered;
await t.app.submitRenameCase();
expect(t.calls, entered).toEqual([]);
expect(t.els.renameCaseError.classes.has('hidden'), entered).toBe(false);
expect(t.els.renameCaseModal.classes.has('active'), entered).toBe(true);
}
});
it('shows the folder checkbox for a linked case and sends its state', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(false);
expect(t.els.renameCaseFolderHint.textContent).toContain('/srv/app');
t.els.renameCaseInput.value = 'beta';
t.els.renameCaseFolder.checked = true;
await t.app.submitRenameCase();
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
const alias = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
alias.app.renameCase('alpha');
alias.els.renameCaseInput.value = 'beta';
await alias.app.submitRenameCase();
expect(alias.calls[0].body).toEqual({ newName: 'beta', renameFolder: false });
});
it('lists the live sessions in the dialog and stops them only on the second press', async () => {
const refusal = {
success: false,
error: '1 live session has this folder open.',
requiresStop: true,
sessions: [{ id: 's', name: 'w1-alpha' }],
};
const t = makeApp({ responses: [refusal, ok], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toContain('w1-alpha');
// "stop" closes them through the server's session cleanup: say that their tabs go away
expect(t.els.renameCaseError.textContent).toContain('They will be closed (their tabs go away');
expect(t.els.renameCaseSubmit.textContent).toBe('Stop sessions and rename');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.calls).toHaveLength(1);
await t.app.submitRenameCase();
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
});
it('says how many sessions were already closed when the rename fails after closing them', async () => {
const refusal = { success: false, error: 'busy', requiresStop: true, sessions: [{ id: 's', name: 'w1-alpha' }] };
const failed = { success: false, error: 'The registry could not be written.', stoppedSessions: 2 };
const t = makeApp({ responses: [refusal, failed], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe(
'The registry could not be written. (2 sessions were already closed.)'
);
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
});
it('cancelling the dialog stops nothing and renames nothing', async () => {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.app.closeRenameCase();
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
await t.app.submitRenameCase(); // nothing open: ignored
expect(t.calls).toEqual([]);
});
it('shows the server’s message inside the dialog when the folder is locked, and leaves the lists alone', async () => {
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
const t = makeApp({ responses: [locked], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe('The folder is in use by another program (EBUSY).');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseSubmit.disabled).toBe(false); // the name can be fixed and tried again
expect(t.toasts).toEqual([]);
expect(t.refreshed).toEqual([]);
expect(t.store.has('caseSettings_alpha')).toBe(true);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* @fileoverview src/case-rename.ts: the in-process lock, the record of sessions still being created, the
* retried folder move and Claude history migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CaseRenameError,
caseSessionSettled,
claudeProjectDirName,
isCasePathLocked,
isCaseSessionStarting,
moveCaseFolder,
noteCaseSessionStarting,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../src/case-rename.js';
import { Session } from '../src/session.js';
const roots: string[] = [];
const tmp = () => {
const dir = mkdtempSync(join(tmpdir(), 'case-rename-'));
roots.push(dir);
return dir;
};
afterEach(async () => {
vi.restoreAllMocks();
for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true });
});
describe('withCaseLock', () => {
it('holds both paths and everything inside them for the length of the call, then lets go', async () => {
const root = tmp();
const from = join(root, 'old');
const to = join(root, 'new');
let during: boolean[] = [];
await withCaseLock(from, to, async () => {
during = [
isCasePathLocked(from),
isCasePathLocked(join(from, 'src')),
isCasePathLocked(to),
isCasePathLocked(root),
];
});
expect(during).toEqual([true, true, true, false]);
expect(isCasePathLocked(from)).toBe(false);
expect(isCasePathLocked(to)).toBe(false);
});
it('refuses an overlapping rename with BUSY and releases on failure', async () => {
const root = tmp();
await withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({
code: 'BUSY',
});
await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf(
CaseRenameError
);
});
await expect(
withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(isCasePathLocked(join(root, 'a'))).toBe(false);
});
it('keeps a session from starting in a folder that is being moved', async () => {
const root = tmp();
const from = join(root, 'case');
mkdirSync(from);
await withCaseLock(from, join(root, 'renamed'), async () => {
expect(() => new Session({ workingDir: from })).toThrow(/being renamed/);
expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/);
});
});
});
describe('sessions still being created', () => {
it('counts a constructed session as starting in its folder until the server settles it', () => {
const root = tmp();
const dir = join(root, 'case');
const session = new Session({ workingDir: join(dir, 'sub') });
expect(isCaseSessionStarting(dir)).toBe(true);
expect(isCaseSessionStarting(join(dir, 'sub'))).toBe(true);
expect(isCaseSessionStarting(join(dir, 'other'))).toBe(false);
expect(isCaseSessionStarting(`${dir}-2`)).toBe(false);
caseSessionSettled(session);
expect(isCaseSessionStarting(dir)).toBe(false);
});
it('lets go of a session that is stopped, and of one never registered once a while has passed', async () => {
const root = tmp();
const stopped = new Session({ workingDir: root });
await stopped.stop();
expect(isCaseSessionStarting(root)).toBe(false);
// a create that failed before registering, or a one-shot prompt that never registers
const lost = {};
noteCaseSessionStarting(lost, root, 1000);
expect(isCaseSessionStarting(root, 1000 + 29_999)).toBe(true);
expect(isCaseSessionStarting(root, 1000 + 30_000)).toBe(false);
});
});
describe('moveCaseFolder', () => {
it('moves a folder and its contents', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x');
await moveCaseFolder(join(root, 'old'), join(root, 'new'));
expect(existsSync(join(root, 'old'))).toBe(false);
expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true);
});
it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
mkdirSync(join(root, 'taken'));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' });
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('retries a lock error, and succeeds once the other program lets go', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
const real = fsp.rename.bind(fsp);
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => {
if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
return real(a, b);
});
await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1);
expect(calls).toBe(3);
expect(existsSync(join(root, 'new'))).toBe(true);
});
it('reports a lock that never clears as LOCKED, with what to do about it', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' }));
const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e);
expect(err).toBeInstanceOf(CaseRenameError);
expect(err.code).toBe('LOCKED');
expect(err.message).toMatch(/in use by another program/);
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('refuses to cross filesystems', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' }));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' });
});
});
describe('Claude history', () => {
it('names a project directory the way Claude Code does', () => {
expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case');
expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase');
});
it('moves the old path’s history to the new one', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false);
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false);
});
it('merges into history already at the new path (a deleted case’s leftovers) without overwriting any of it', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(join(from, 'conv-a', 'subagents'), { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
writeFileSync(join(from, 'conv-a', 'subagents', 'agent-1.jsonl'), 'alpha agent');
mkdirSync(join(from, 'memory'));
writeFileSync(join(from, 'memory', 'MEMORY.md'), 'alpha memory');
// Claude Code never removes a project directory, so a case deleted earlier under the new name left this.
mkdirSync(join(to, 'memory'), { recursive: true });
writeFileSync(join(to, 'conv-old.jsonl'), 'old beta');
writeFileSync(join(to, 'memory', 'MEMORY.md'), 'old beta memory');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(readFileSync(join(to, 'conv-a.jsonl'), 'utf8')).toBe('alpha');
expect(readFileSync(join(to, 'conv-a', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('alpha agent');
expect(readFileSync(join(to, 'conv-old.jsonl'), 'utf8')).toBe('old beta');
// an entry both have is never overwritten: it stays where it was
expect(readFileSync(join(to, 'memory', 'MEMORY.md'), 'utf8')).toBe('old beta memory');
expect(readFileSync(join(from, 'memory', 'MEMORY.md'), 'utf8')).toBe('alpha memory');
expect(existsSync(join(from, 'conv-a.jsonl'))).toBe(false);
});
it('removes the old project directory once a merge has emptied it, and never merges into a symlink', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(from, { recursive: true });
mkdirSync(to, { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(from)).toBe(false);
const elsewhere = tmp();
const linkedPath = join(tmp(), 'gamma');
mkdirSync(from);
writeFileSync(join(from, 'conv-b.jsonl'), 'alpha');
symlinkSync(elsewhere, join(projects, claudeProjectDirName(linkedPath)));
expect(await moveClaudeHistory(oldPath, linkedPath, cfg)).toBe(false);
expect(existsSync(join(from, 'conv-b.jsonl'))).toBe(true);
expect(existsSync(join(elsewhere, 'conv-b.jsonl'))).toBe(false);
});
});
describe('small helpers', () => {
it('renames in an order list keeping the position, and says nothing when absent', () => {
expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']);
expect(renameInOrder(['a'], 'q', 'z')).toBeNull();
expect(renameInOrder(undefined, 'a', 'z')).toBeNull();
});
it('puts the new name next to the old folder', () => {
expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2');
});
});
+216
View File
@@ -13,8 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentAck,
agentInboxRead,
agentInterrupt,
agentLs,
agentPost,
agentRead,
agentRm,
agentSend,
@@ -28,13 +31,16 @@ import {
deleteRefusal,
describeFailure,
httpRequest,
inboxCell,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
postTextFromArgs,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
terminalSafeText,
waitExitCode,
type AgentContext,
type AgentDeps,
@@ -607,6 +613,196 @@ describe('session id prefixes', () => {
});
});
describe('agent inbox verbs', () => {
const MSG = {
id: 'm-1',
from: OTHER,
text: 'line one\nline two',
createdAt: 1_700_000_000_000 - 90_000,
seen: false,
};
it('ls merges the inbox summary into an INBOX column', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({
sessions: {
[SELF]: { pending: 2, unseen: 1, oldestUnseenAt: 1, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
},
})
: ok([
{ id: SELF, mode: 'claude', status: 'busy', name: 'me' },
{ id: OTHER, mode: 'codex', status: 'idle', name: 'worker' },
])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/ID\s+MODE\s+STATUS\s+INBOX\s+NAME/);
expect(text).toMatch(/058ee7b5\s+claude\s+busy\s+2 \(1 new\)\s+me/);
expect(text).toMatch(/94990c6d\s+codex\s+idle\s+parked\s+worker/);
});
it('ls --json carries each session’s inbox row (null when it has none)', async () => {
const deps = fakeDeps(
(o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({ sessions: { [OTHER]: { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false } } })
: ok([{ id: SELF }, { id: OTHER }]),
true
);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; inbox: unknown }>;
expect(parsed.map((s) => s.inbox)).toEqual([null, { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false }]);
});
it('ls still lists sessions when the server has no summary route', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary' ? apiError(404, 'NOT_FOUND', 'Route not found') : ok([{ id: OTHER }])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
expect(deps.out.join('\n')).not.toContain('INBOX');
});
it('inboxCell', () => {
expect(inboxCell(undefined)).toBe('');
expect(inboxCell({ pending: 3, unseen: 0, oldestUnseenAt: null, parked: false })).toBe('3');
expect(inboxCell({ pending: 3, unseen: 2, oldestUnseenAt: 1, parked: true })).toBe('3 (2 new), parked');
});
it('post sends the text as data to the resolved session and never to /input', async () => {
const deps = fakeDeps([ok({ message: MSG, pending: 1, unseen: 1 })]);
expect(await agentPost(deps, { id: OTHER, text: 'line one\nline two' })).toBe(EXIT.ok);
expect(deps.calls).toEqual([
expect.objectContaining({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/inbox`,
body: { text: 'line one\nline two' },
}),
]);
expect(deps.out.join('')).toMatch(/posted to 94990c6d \(1 pending, 1 unseen\)/);
});
it('post passes a full inbox (409) through as an error', async () => {
const deps = fakeDeps([apiError(409, 'CONFLICT', 'Inbox of x is full (200 messages).')]);
expect(await agentPost(deps, { id: OTHER, text: 'x' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/CONFLICT: Inbox of x is full/);
});
it('post takes ONE argument and refuses an empty one before any request', async () => {
expect(postTextFromArgs(['a\nb'])).toEqual({ text: 'a\nb' });
expect(postTextFromArgs(['a', 'b'])).toEqual({ error: expect.stringMatching(/ONE argument, got 2/) });
const deps = fakeDeps([]);
expect(await agentPost(deps, { id: OTHER, text: '' })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('inbox reads this session’s own inbox by default and prints full ids plus the ack hint', async () => {
const deps = fakeDeps([ok({ receiver: true, messages: [MSG], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'GET', path: `/api/v1/sessions/${SELF}/inbox` });
expect(deps.calls[0].query).toEqual({ wait: undefined });
const text = deps.out.join('\n');
expect(text).toMatch(/── m-1 from 94990c6d, 2m ago, new/);
expect(text).toContain('line one\nline two');
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
});
it('inbox prints message text with escape sequences and control characters neutralized', async () => {
const hostile = {
...MSG,
text: 'title \x1b]0;pwned\x07ok\r\nred \x1b[31mtext\x1b[0m\rover\bwrite\ttab\x9b2J\nend',
};
const deps = fakeDeps([ok({ receiver: true, messages: [hostile], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
// out[0] is the `── <id> from …` header (styled by the palette), out[1] the text.
expect(deps.out).toHaveLength(2);
expect(deps.out[1]).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/);
expect(deps.out[1]).toBe('title ok\nred text\\x0dover\\x08write\ttab\\x9b2J\nend');
});
it('terminalSafeText keeps newline and tab and escapes every other control', () => {
expect(terminalSafeText('a\nb\tc')).toBe('a\nb\tc');
expect(terminalSafeText('a\r\nb')).toBe('a\nb');
expect(terminalSafeText('\x1b[2J\x1bc\x00\x7f')).toBe('\\x1bc\\x00\\x7f');
expect(terminalSafeText('ünïcödé ✓')).toBe('ünïcödé ✓');
});
it('inbox --json hands the text out as stored', async () => {
const raw = { ...MSG, text: 'red \x1b[31mtext' };
const deps = fakeDeps([ok({ receiver: true, messages: [raw], pending: 1, unseen: 0 })], true);
await agentInboxRead(deps, {});
expect((JSON.parse(deps.out.join('')) as { messages: Array<{ text: string }> }).messages[0].text).toBe(
'red \x1b[31mtext'
);
});
it('inbox <id> is a peek and says so', async () => {
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
expect(deps.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/inbox`);
expect(deps.err.join('')).toMatch(/peek: nothing was marked seen/);
});
it('inbox --wait passes the budget and maps timeout to exit 2', async () => {
const timedOut = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { timedOut: true } })]);
expect(await agentInboxRead(timedOut, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(timedOut.calls[0]).toMatchObject({ query: { wait: 30_000 }, timeoutMs: 40_000 });
});
describe('a wait the server ended', () => {
const ended = ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { ended: true } });
it('is exit 3 only when the session list confirms the session is gone', async () => {
const deps = fakeDeps([ended, ok([{ id: OTHER }])]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.dead);
expect(deps.calls[1]).toMatchObject({ method: 'GET', path: '/api/v1/sessions' });
expect(deps.err.join('')).toMatch(/went away/);
});
it('is exit 2 (wait again) while the session is still listed: a restart or detach', async () => {
const deps = fakeDeps([ended, ok([{ id: SELF }])]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(deps.err.join('')).toMatch(/ended the wait early/);
});
it('is exit 2 when the list cannot be had (the server is still coming back)', async () => {
const deps = fakeDeps([ended, apiError(503, 'INTERNAL_ERROR', 'restarting')]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
});
});
it('ack posts the ids to this session’s own inbox', async () => {
const deps = fakeDeps([ok({ removed: 2, pending: 0 })]);
expect(await agentAck(deps, { ids: ['m-1', ' m-2 '] })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${SELF}/inbox/ack`,
body: { ids: ['m-1', 'm-2'] },
});
expect(deps.out.join('')).toMatch(/acked 2 \(0 still pending\)/);
});
it('ack refuses without ids and passes a 403 through', async () => {
const none = fakeDeps([]);
expect(await agentAck(none, { ids: [' '] })).toBe(EXIT.refused);
expect(none.calls).toEqual([]);
const forbidden = fakeDeps([apiError(403, 'FORBIDDEN', 'Only x itself can acknowledge its inbox')]);
expect(await agentAck(forbidden, { ids: ['m-1'] })).toBe(EXIT.error);
});
it('none of the inbox verbs writes to a pane', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([{ id: OTHER }]) : ok({ messages: [], pending: 0, unseen: 0 })
);
await agentPost(deps, { id: OTHER, text: 'x' });
await agentInboxRead(deps, {});
await agentInboxRead(deps, { id: OTHER, waitMs: 1000 });
await agentAck(deps, { ids: ['m'] });
for (const call of deps.calls) expect(call.path).not.toMatch(/\/input$/);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
@@ -621,6 +817,26 @@ describe('help texts carry the traps the skill documents', () => {
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
});
it('post takes text that starts with "-" (markdown from a file) without --', async () => {
const program = new Command();
program.exitOverride();
const post = registerAgentCommands(program).commands.find((c) => c.name() === 'post')!;
const got: Array<{ words: string[]; json: boolean }> = [];
post.action((_id: string, words: string[], o: { json?: boolean }) => {
got.push({ words, json: Boolean(o.json) });
});
for (const text of ['- [ ] item\nnext', '---\ntitle: a', '-v is great']) {
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', text]);
}
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', 'hi', '--json']);
expect(got).toEqual([
{ words: ['- [ ] item\nnext'], json: false },
{ words: ['---\ntitle: a'], json: false },
{ words: ['-v is great'], json: false },
{ words: ['hi'], json: true },
]);
});
it('rm does not claim a lineage check it does not make', () => {
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Codex's selection menus (the "Implement this plan?" prompt, the /model, /permissions and /review pickers,
* approvals, questions, the trust prompt) take PageUp and PageDown as "move the selection". Codeman turns
* the wheel into those keys for a Codex pane that does not track the mouse (codex-cli 0.162 and 0.164 never
* do), so a wheel notch walked the cursor through the options: one notch down landed on "No, stay in Plan
* mode", one up on "Yes, implement this plan". The pane must consume the wheel there and send nothing.
*
* The screens are the live `tmux capture-pane` frames in test/fixtures/codex-dialogs/.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const fixture = (name: string) => readFileSync(resolve(import.meta.dirname, 'fixtures/codex-dialogs', name), 'utf8');
/** A terminal whose screen shows `text`, padded to `rows` rows (what xterm holds for a hollow pane). */
function terminalShowing(text: string, rows = 40) {
const lines = text.replace(/\n+$/, '').split('\n');
// A capture taller than the pane keeps its bottom rows (the menu), a shorter one sits at the top.
const shown = lines.length > rows ? lines.slice(-rows) : lines;
const screen = Array.from({ length: rows }, (_, i) => shown[i] ?? '');
return {
cols: 100,
rows,
modes: { mouseTrackingMode: 'none' },
buffer: {
active: {
type: 'normal',
baseY: 0,
viewportY: 0,
length: rows,
getLine: (y: number) => (screen[y] === undefined ? undefined : { translateToString: () => screen[y] }),
},
},
};
}
function load() {
const CodemanApp = function CodemanApp(this: any) {};
const windowRef: Record<string, any> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES };
const context = vm.createContext({
window: windowRef,
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
clearTimeout: () => undefined,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => true },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8'), context, { filename: 'terminal-ui.js' });
return { app: new (CodemanApp as any)(), windowRef };
}
/** Every dialog or menu a Codex pane can be sitting in, and the screens where PageUp must still page. */
const MENUS = [
'implement-plan.txt',
'model-popup.txt',
'permissions-popup.txt',
'review-popup.txt',
'exec-approval.txt',
'exec-approval-w36.txt',
'edit-approval.txt',
'question.txt',
'question-1of2.txt',
'question-notes.txt',
'trust-dialog.txt',
'trust-dialog-back.txt',
'trust-dialog-w40.txt',
'exec-approval-w44.txt',
'exec-approval-60x14.txt',
'exec-approval-long-command.txt',
'network-escalation-approval.txt',
'question-option2-selected.txt',
'question-2of2-submit-all.txt',
'question-notes-typed.txt',
'question-w36.txt', // the key hint one segment per row
'question-w60.txt',
];
const NOT_MENUS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt',
'slash-popup.txt',
'transcript-quotes-dialog.txt',
'composer-typed-dialog-text.txt',
'composer-typed-dialog-text-w60.txt',
];
describe('isCodexSelectionMenu on live captures', () => {
const { windowRef } = load();
const isMenu = (name: string) => windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminalShowing(fixture(name)));
it.each(MENUS)('reads %s as a menu', (name) => {
expect(isMenu(name), name).toBe(true);
});
it.each(NOT_MENUS)('does not read %s as a menu', (name) => {
expect(isMenu(name), name).toBe(false);
});
it('still reads a narrow question whose option descriptions wrap over many rows', () => {
// The selected option sat 16 rows up from the bottom at 36 columns; sentence-length descriptions on
// a phone-width pane push it further, and an earlier row cap lost it (the menu was missed and the
// wheel moved the selection again).
const rows = fixture('question-w36.txt').split('\n');
const at = rows.findIndex((row) => /^\s{0,2}› 1\./.test(row));
expect(at).toBeGreaterThan(-1);
rows.splice(at + 1, 0, ...Array.from({ length: 12 }, (_, i) => ` wrapped description row ${i + 1}`));
const terminal = terminalShowing(rows.join('\n'), 60);
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminal)).toBe(true);
});
it('is false for a terminal it cannot read', () => {
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(undefined)).toBe(false);
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu({ rows: 10, buffer: { active: { baseY: 0 } } })).toBe(
false
);
});
});
describe('the wheel over a Codex pane', () => {
function codexPane(screen: string) {
const { app } = load();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: false }]]);
app._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
app.terminal = terminalShowing(screen);
return { app, sent };
}
it('sends no page key while the "Implement this plan?" menu is open, and consumes the wheel', () => {
const { app, sent } = codexPane(fixture('implement-plan.txt'));
for (const lines of [-30, 30, -30, 30]) expect(app._maybePageCliTranscript({ shiftKey: false }, lines)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('still pages the transcript from the composer', () => {
const { app, sent } = codexPane(fixture('composer-typed.txt'));
expect(app._maybePageCliTranscript({ shiftKey: false }, -30)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual(['\x1b[5~']);
});
it('only looks at Codex: another page-key CLI showing the same text is paged as before', () => {
const { app, sent } = codexPane(fixture('implement-plan.txt'));
app.sessions.set('sess-1', { mode: 'opencode' });
app._maybePageCliTranscript({ shiftKey: false }, -30);
app._flushWheelSgrQueue();
expect(sent).toEqual(['\x1b[5~']);
});
});
describe('a tile asks the same question', () => {
it('consumes the wheel for an open Codex menu in its own terminal and session', () => {
const tile = readFileSync(resolve(publicDir, 'terminal-tile.js'), 'utf8');
expect(tile).toContain('app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })');
const { app } = load();
app.sessions = new Map([
['active', { mode: 'claude' }],
['tile', { mode: 'codex' }],
]);
app.terminal = terminalShowing(fixture('composer-typed.txt'));
const tileTerminal = terminalShowing(fixture('implement-plan.txt'));
// the tile's own terminal and session decide, not the active pane's
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'tile' })).toBe(true);
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'active' })).toBe(false);
expect(app._codexMenuOpen()).toBe(false);
});
});
+330 -1
View File
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadPaletteHarness(overrides: Record<string, any> = {}) {
function loadPaletteHarness(overrides: Record<string, any> = {}, { sessionUi = false } = {}) {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
const CodemanApp = function CodemanApp(this: any) {};
@@ -70,6 +70,12 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
// session-ui.js loads after panels-ui.js in index.html. Opt-in: it also brings
// the real selectQuickStartCase, which the case-pick tests above stub around.
if (sessionUi) {
const sessionUiSrc = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUiSrc, context, { filename: 'session-ui.js' });
}
const app = new (CodemanApp as any)();
app.sessions = new Map([
@@ -341,6 +347,329 @@ describe('Command-K session palette', () => {
});
});
const PALETTE_CATALOG = [
{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true },
{ id: 'shell', label: 'Shell', kind: 'shell', enabled: true },
{ id: 'codex', label: 'Codex', kind: 'agent', enabled: true },
{ id: 'opencode', label: 'OpenCode', kind: 'agent', enabled: true },
{ id: 'gemini', label: 'Gemini', kind: 'agent', enabled: false },
];
/** A palette whose agent list comes from the injected CLI catalog, codex not installed. */
function loadAgentPalette(catalog: any[] = PALETTE_CATALOG, options: { sessionUi?: boolean } = {}) {
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
const harness = loadPaletteHarness(
{ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } },
options
);
// Mirrors settings-ui.js isCliAvailable(): unknown reads as available.
harness.app.isCliAvailable = (id: string) => available[id] !== false;
harness.app._runMode = 'claude';
return harness;
}
function arrow(key: 'ArrowLeft' | 'ArrowRight', caret: number, value = '') {
return {
key,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { value, selectionStart: caret, selectionEnd: caret },
};
}
describe('Command-K new-session agent picker', () => {
it('offers the enabled, installed CLIs from the registry catalog, in catalog order', () => {
const { app } = loadAgentPalette();
expect(app._commandPaletteAgentChoices().map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
});
it('starts on the Run button mode and labels the row from the catalog', () => {
const { app, elements } = loadAgentPalette();
app._runMode = 'opencode';
app.openCommandPalette();
const item = app.buildCommandPaletteItems('flux').find((i: any) => i.type === 'new-session');
expect(item).toMatchObject({ mode: 'opencode', subtitle: 'Run OpenCode in flux-player' });
expect(elements.commandPaletteList.innerHTML).toContain('data-palette-agent-cycle="next"');
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot opencode');
});
it('keeps the switch out of the row button accessible name and gives its tooltip a zh-CN entry', () => {
const { app, elements } = loadAgentPalette();
app.openCommandPalette();
const html = elements.commandPaletteList.innerHTML;
const title = 'Agent for the new session (Left/Right to change)';
// No nested buttons inside the row's <button>: the subtitle names the agent.
expect(html).toContain(`class="command-palette-agent" aria-hidden="true" title="${title}"`);
expect(html).not.toContain('role="button"');
expect(html).not.toContain('aria-label=');
const i18n = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
expect(i18n.split(`'${title}':`).length - 1).toBe(1);
});
it('falls back to the first offered agent when the Run button mode is not offered', () => {
const { app } = loadAgentPalette();
app._runMode = 'codex'; // not installed
app.openCommandPalette();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles the agent with Right/Left on the new-session row, wrapping', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].type).toBe('new-session');
const right = arrow('ArrowRight', 7, 'nomatch');
await listeners['search:keydown'](right);
expect(right.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'shell' });
expect(elements.commandPaletteList.innerHTML).toContain('Run Shell in');
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
});
it('leaves Left/Right to the caret when they would move it', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const left = arrow('ArrowLeft', 7, 'nomatch');
const right = arrow('ArrowRight', 3, 'nomatch');
await listeners['search:keydown'](left);
await listeners['search:keydown'](right);
expect(left.preventDefault).not.toHaveBeenCalled();
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
it('does not cycle while a session row is highlighted', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
expect(app.commandPaletteItems[0].type).toBe('session');
const right = arrow('ArrowRight', 0);
await listeners['search:keydown'](right);
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles from the row arrows without launching', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
const cycle = { dataset: { paletteAgentCycle: 'next' } };
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { closest: (sel: string) => (sel === '[data-palette-agent-cycle]' ? cycle : null) },
};
listeners['list:click'](event);
expect(app.run).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('shell');
});
it('launches the chosen agent once, without changing the Run button mode', async () => {
const { app } = loadAgentPalette();
app.setRunMode = vi.fn();
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(2); // claude -> shell -> opencode
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(app.run).toHaveBeenCalledTimes(1);
expect(modeDuringRun).toBe('opencode');
expect(app._runMode).toBe('claude');
expect(app.setRunMode).not.toHaveBeenCalled();
});
it('restores the Run button mode even when the launch throws', async () => {
const { app } = loadAgentPalette();
app.run = vi.fn(async () => {
throw new Error('boom');
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(1);
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await expect(app.activateCommandPaletteItem()).rejects.toThrow('boom');
expect(app._runMode).toBe('claude');
});
it('draws no picker when only one agent is offered', () => {
const { app, elements } = loadAgentPalette([{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true }]);
app.openCommandPalette();
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
});
it('keeps the Run button mode when a single other agent is offered', async () => {
// Only Shell is offered while the Run button names claude: with nothing to
// pick between, the row keeps the Run button's mode, as before the picker
// existed, so run() answers a missing claude with its install hint instead
// of the palette silently starting Shell.
const { app, elements } = loadAgentPalette(PALETTE_CATALOG.slice(0, 2));
app.isCliAvailable = (id: string) => id !== 'claude';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app.commandPaletteItems[index]).toMatchObject({ mode: 'claude', subtitle: 'Run Claude in plex-previews' });
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('claude');
expect(app._runMode).toBe('claude');
});
});
/** The toolbar Run menu's entries, as renderRegistryRunOptions() and index.html draw them. */
function fakeRunMenu(modes: string[]) {
const options = modes.map((mode) => ({ dataset: { mode }, style: { display: 'PRISTINE' } }));
return {
options,
querySelectorAll: (selector: string) => (selector === '.run-mode-option[data-mode]' ? options : []),
querySelector: () => null,
prepend: vi.fn(),
};
}
const shownByRunMenu = (menu: ReturnType<typeof fakeRunMenu>) =>
menu.options.filter((option) => option.style.display === 'flex').map((option) => option.dataset.mode);
/**
* A palette with session-ui.js loaded, so the agent list goes through the Run
* menu's real availability rule. Host flags as in loadAgentPalette (no codex
* here); the active case is a Docker case with the given `docker` record.
*/
function loadDockerPalette(docker: Record<string, any>) {
const harness = loadAgentPalette(PALETTE_CATALOG, { sessionUi: true });
harness.app.cases = [
{ name: 'plex-previews' },
{ name: 'far', location: 'remote' },
{ name: 'dock', location: 'docker', docker },
];
harness.elements.quickStartCase.value = 'dock';
harness.app.selectQuickStartCase = vi.fn();
// The Run menu's live container probe is a docker exec round trip; here it
// never lands, which is also what a probe still in flight looks like.
harness.app._probeDockerCaseModes = vi.fn();
return harness;
}
const ADOPTED = { owned: false, hostId: 'local', container: 'dev-box', availableModes: ['shell', 'claude', 'codex'] };
describe('Command-K new-session agents follow the Run menu for the row case', () => {
it('offers a Docker case its container agents and starts on a container-only Run mode', async () => {
// codex is missing on the host but ships in the container, and the Run
// button is on it (picked from the Run menu, which gates on the container).
const { app, elements } = loadDockerPalette(ADOPTED);
app._runMode = 'codex';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['claude', 'shell', 'codex']);
expect(app.commandPaletteItems[index]).toMatchObject({
caseName: 'dock',
mode: 'codex',
subtitle: 'Run Codex in dock',
});
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot codex');
// The same agents the Run menu shows for this case.
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex']);
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('codex');
expect(app._runMode).toBe('codex');
});
it('offers every enabled agent for an owned container nothing has probed yet, and then follows the probe', () => {
// An owned container runs the base image, which ships every CLI, and before
// its first session there is nothing to probe: like the Run menu, an unknown
// list does not gate, so a probe still in flight cannot empty the row.
const { app } = loadDockerPalette({ owned: true, hostId: 'local', container: 'codeman-dock' });
app._runMode = 'codex';
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'codex',
'opencode',
]);
expect(app.commandPaletteItems.find((i: any) => i.type === 'new-session').mode).toBe('codex');
// The palette reads what the Run menu learned; it never starts a docker exec itself.
expect(app._probeDockerCaseModes).not.toHaveBeenCalled();
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex', 'opencode']);
// Once the live probe has landed, both follow it.
app._dockerCaseModes = { dock: ['shell', 'opencode'] };
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['shell', 'opencode']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['opencode']);
});
it('keeps the host rule for local and remote cases, and follows the case the query names', () => {
const { app, elements } = loadDockerPalette(ADOPTED);
elements.quickStartCase.value = 'plex-previews';
app._runMode = 'codex'; // missing on the host
const row = () => app.commandPaletteItems.find((i: any) => i.type === 'new-session');
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('plex-previews').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'opencode',
]);
expect(app._commandPaletteAgentChoices('far').map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
expect(row()).toMatchObject({ caseName: 'plex-previews', mode: 'claude' });
// Typing the Docker case's name moves the row there, where the container
// has the Run button's codex, so the row starts on it.
elements.commandPaletteSearch.value = 'dock';
app.renderCommandPalette();
expect(row()).toMatchObject({ caseName: 'dock', mode: 'codex' });
});
});
describe('Session Manager unified list', () => {
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
const { app, elements } = loadPaletteHarness({
+12 -5
View File
@@ -299,8 +299,8 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
* exactly what happened twice: a comment added above the assertion pushed the
* asserted line past the cutoff and CI failed on a test that was still true.
*/
const refreshFn = (src) => {
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
const methodFn = (src, signature) => {
const start = src.indexOf(signature);
expect(start).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
let depth = 0;
@@ -308,23 +308,30 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error('unbalanced braces in _refreshRunModeAvailability');
throw new Error(`unbalanced braces in ${signature}`);
};
// The per-mode decision lives in ONE helper, shared by the Run menu and the
// Cmd+K new-session row (panels-ui.js), so the two cannot drift apart.
const ruleFn = (src) => methodFn(src, '_runModeAvailableForCase(mode, caseName) {');
it('gates a docker case on availableModes instead of host CLI probes', () => {
// The sandbox host had codex but no claude while the adopted container had
// claude and no codex; gating on the host hid the only mode that worked.
const fn = refreshFn(ui);
const fn = ruleFn(ui);
expect(fn).toContain("location === 'docker'");
expect(fn).toContain('availableModes');
// Non-docker cases must keep the original host probe (#201).
expect(fn).toContain('this.isCliAvailable(mode)');
// And the Run menu asks that helper rather than keeping its own copy.
expect(methodFn(ui, '_refreshRunModeAvailability(menu) {')).toContain(
'this._runModeAvailableForCase(mode, caseName)'
);
});
it('leaves an owned container ungated when nothing was probed', () => {
// Our base image ships every CLI, so an absent list means "unknown", and
// treating unknown as "nothing available" would empty the menu.
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
expect(ruleFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
});
});
+10
View File
@@ -106,6 +106,16 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
expect(cmd).toContain("sh -lc '");
// The in-container tmux keeps prefix C-q. Without this reduction its stock
// prefix table runs switch-client / choose-tree for the inner client.
// Bind, unbind-all, bind again: an empty prefix table makes unbind-key -a
// fail and abort the rest of the \; chain.
const firstBind = cmd.indexOf('bind-key -T prefix C-q send-prefix');
const unbind = cmd.indexOf('unbind-key -a -T prefix');
const secondBind = cmd.indexOf('bind-key -T prefix C-q send-prefix', unbind);
expect(firstBind).toBeGreaterThan(cmd.indexOf('prefix C-q'));
expect(unbind).toBeGreaterThan(firstBind);
expect(secondBind).toBeGreaterThan(unbind);
});
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
@@ -33,6 +33,9 @@ const SCANNED_FILES = [
'terminal-split.js',
'tile-grid.js',
'terminal-tile.js',
// The command palette's new-session agent picker reads its agents from the
// injected catalog; panels-ui.js carries no id branch, so keep it that way.
'panels-ui.js',
];
/**
@@ -180,3 +180,101 @@ describe('tab switch to a pane that keeps no scrollback', () => {
await context.close();
}, 90_000);
});
describe('a tab switch whose tail replays no scrollback while the pane holds some', () => {
it('scrolls by pulling the full history instead of paging the CLI', async () => {
// After a server restart the byte recording is only the tmux client's attach
// repaint (home + clear + absolute redraws), so the tail a second select
// replays leaves baseY 0 while the response still reports tmux scrollback.
// The scroll used to page the CLI (PageUp into an inline Claude's composer);
// it now falls through to the local path, whose top-of-buffer pull fetches
// `full=1` and the gesture then scrolls real history.
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
const page = await context.newPage();
await openApp(page);
const inline = await createSession(page, 'inline-claude-after-restart');
const other = await createSession(page, 'other');
const log: string[] = [];
const history = Array.from({ length: 200 }, (_, i) => `history line ${i}\r\n`).join('');
await page.route('**/api/sessions/*/terminal*', async (route) => {
const url = new URL(route.request().url());
const id = url.pathname.split('/')[3];
const full = url.searchParams.get('full') === '1';
log.push(`${id}:${full ? 'full' : 'tail'}`);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: full ? `${history}frame of ${id}\r\n` : `\x1b[H\x1b[2Jframe of ${id}\r\n`,
status: 'idle',
fullSize: 4096,
retainedBytes: 4096,
truncated: false,
truncationReason: null,
source: full ? 'mux-full-history' : 'mux-visible',
paneHistoryLines: 200,
},
}),
});
});
await select(page, inline);
await select(page, other);
await select(page, inline);
expect(log).toEqual([`${inline}:full`, `${other}:full`, `${inline}:tail`]);
const baseYAfterTail = await page.evaluate(
() => (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active.baseY
);
expect(baseYAfterTail).toBe(0);
// The prerequisite the guard answers: by the old rule this pane IS hollow.
expect(await page.evaluate(() => (window as unknown as { app: any }).app._localScrollbackIsHollow())).toBe(true);
// Record what the scroll sends to the CLI, then wheel up over the terminal.
await page.evaluate(() => {
const app = (window as unknown as { app: any }).app;
app.__sentToCli = [];
app._sendInputEphemeral = (_id: string, data: string) => app.__sentToCli.push(data);
});
log.length = 0;
const box = await page.locator('#terminalContainer').boundingBox();
if (!box) throw new Error('terminal container not laid out');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -400);
await page.waitForFunction(
() => (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active.baseY > 0,
null,
{ timeout: 10_000 }
);
expect(log).toEqual([`${inline}:full`]);
// The next gesture scrolls the recovered history itself: older text comes
// into view, nothing further is fetched, and the CLI still receives nothing
// (the page-key queue flushes 40 ms after a gesture, so settle first).
await page.mouse.wheel(0, -400);
await page.waitForFunction(
() => {
const b = (window as unknown as { app: { terminal: any } }).app.terminal.buffer.active;
return b.viewportY < b.baseY;
},
null,
{ timeout: 10_000 }
);
await page.waitForTimeout(200);
const view = await page.evaluate(() => {
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
const b = t.buffer.active;
const lines: string[] = [];
for (let i = b.viewportY; i < b.viewportY + t.rows; i++) lines.push(b.getLine(i)?.translateToString(true) ?? '');
return lines.join('\n');
});
expect(view).toContain('history line ');
expect(log).toEqual([`${inline}:full`]);
expect(await page.evaluate(() => (window as unknown as { app: any }).app.__sentToCli)).toEqual([]);
await deleteSession(page, inline);
await deleteSession(page, other);
await context.close();
}, 90_000);
});
+278
View File
@@ -0,0 +1,278 @@
/**
* Real Chromium, against the real header markup from index.html and the real styles.css: dragging the divider
* resizes the toolbar and wraps it onto a second line, the readings shed detail as it narrows, nothing pokes
* out past the divider (even for content that arrives later), and none of it applies where the handle is hidden.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const dir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8');
const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8');
const header = /<header class="header">[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0];
const CHIP =
'<span class="pu-row"><span data-short="CL" class="pu-provider">Claude</span><span class="pu-windows">' +
'<span class="pu-win"><span class="pu-ring"></span><span class="pu-label">5h</span><span class="pu-val">49%</span></span>' +
'<span class="pu-win"><span class="pu-ring"></span><span class="pu-label">7d</span><span class="pu-val">72%</span></span>' +
'</span></span><span class="pu-row"><span data-short="CP" class="pu-provider">Copilot</span><span class="pu-windows">' +
'<span class="pu-win"><span class="pu-ring"></span><span class="pu-label">mo</span><span class="pu-val">81%</span></span></span></span>';
const ORIGIN = 'http://app.test/';
/** The default layout attributes, with any `name="value"` in `extra` replacing the matching default. */
function rootAttrs(extra = '') {
const attrs = new Map([
['data-header-stats', 'compact'],
['data-tab-orientation', 'horizontal'],
['data-session-list', 'header'],
]);
for (const [, name, value] of extra.matchAll(/([\w-]+)="([^"]*)"/g)) attrs.set(name, value);
return [...attrs].map(([name, value]) => `${name}="${value}"`).join(' ');
}
type Attrs = { root?: string; body?: string };
describe('header divider (real Chromium)', () => {
let browser: Browser;
let p: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
/** Open the real header; `stored` is the width kept from an earlier visit, `chip` fills the plan-usage chip. */
async function open(options: { root?: string; body?: string; stored?: number; chip?: boolean; width?: number } = {}) {
p = await browser.newPage({ viewport: { width: options.width ?? 1400, height: 600 } });
await p.route(ORIGIN, (route) =>
route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html ${rootAttrs(options.root)}><head><style>${styles}</style></head><body class="${options.body ?? ''}">${header}</body></html>`,
})
);
await p.goto(ORIGIN);
if (options.stored)
await p.evaluate((w) => localStorage.setItem('codeman-header-right-width', String(w)), options.stored);
if (options.chip) {
await p.evaluate((html) => {
const chip = document.getElementById('planUsageChip')!;
chip.classList.remove('header-plan-usage--hidden');
chip.innerHTML = html;
}, CHIP);
}
await p.addScriptTag({ content: controller });
}
const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON());
const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const dragTo = async (toolbarWidth: number) => {
const handle = await box('.header-divider');
const head = await box('.header');
await p.mouse.move(handle.left + 6, handle.top + 10);
await p.mouse.down();
await p.mouse.move(head.right - 12 - toolbarWidth, handle.top + 10, { steps: 6 });
await p.mouse.up();
await settled();
};
const readingsInside = async () => {
const edge = (await box('#headerRight')).left + 1;
const lefts = await p
.locator('#headerRight > :not(.header-divider)')
.evaluateAll((els) =>
els.filter((el) => (el as HTMLElement).offsetParent !== null).map((el) => el.getBoundingClientRect().left)
);
return { edge, lefts };
};
const display = (sel: string) =>
p
.locator(sel)
.first()
.evaluate((el) => getComputedStyle(el).display);
it('narrows the toolbar and wraps it when dragged right, and resets on double-click', async () => {
await open({ chip: true });
const before = await box('#headerRight');
await dragTo(330);
const after = await box('#headerRight');
expect(after.width).toBeLessThan(before.width - 100);
expect(after.height).toBeGreaterThan(before.height + 10);
expect((await box('.session-tabs-host')).width).toBeGreaterThan(before.width / 2);
expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).not.toBeNull();
await p.locator('.header-divider').dblclick();
expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2);
expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).toBeNull();
await p.close();
});
it('resizes from the keyboard and tells the tab strip to re-measure', async () => {
await open({ chip: true });
await p.evaluate(() => {
(window as any).relayouts = 0;
(window as any).app = {
updateTabOverflowMode: () => (window as any).relayouts++,
updateConnectionLines: () => {},
};
});
const before = await box('#headerRight');
await p.locator('.header-divider').focus();
await p.keyboard.press('ArrowRight');
await p.keyboard.press('ArrowRight');
expect((await box('#headerRight')).width).toBeLessThan(before.width - 20);
expect(await p.evaluate(() => (window as any).relayouts)).toBeGreaterThanOrEqual(2);
await p.keyboard.press('Home');
expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2);
await p.close();
});
it('never stores a width below the minimum, and widens on the first ArrowLeft after a held ArrowRight', async () => {
await open({ chip: true });
const stored = () => p.evaluate(() => Number(localStorage.getItem('codeman-header-right-width')));
// a drag into the window's last pixels used to store a negative width, which the next load dropped
await dragTo(-6);
expect(await stored()).toBeGreaterThanOrEqual(160);
await p.locator('.header-divider').dblclick();
await p.locator('.header-divider').focus();
for (let i = 0; i < 60; i++) await p.keyboard.press('ArrowRight');
await settled();
expect(await stored()).toBeGreaterThanOrEqual(160);
const floor = (await box('#headerRight')).width;
// Escape is the global close-panels key, not a reset
await p.keyboard.press('Escape');
await settled();
expect(Math.abs((await box('#headerRight')).width - floor)).toBeLessThan(2);
await p.keyboard.press('ArrowLeft');
await settled();
expect((await box('#headerRight')).width).toBeGreaterThan(floor);
await p.close();
});
it('stops the divider at the widest reading so nothing pokes out past it', async () => {
await open({ chip: true });
await dragTo(20);
const { edge, lefts } = await readingsInside();
for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge);
await p.close();
});
it('keeps a reading inside the divider when it arrives after the width was applied', async () => {
await open({ stored: 160 });
await settled();
await p.evaluate((html) => {
const chip = document.getElementById('planUsageChip')!;
chip.classList.remove('header-plan-usage--hidden');
chip.innerHTML = html;
}, CHIP);
await settled();
const { edge, lefts } = await readingsInside();
for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge);
await p.close();
});
it('keeps a wide reading that is switched on later inside the divider', async () => {
await open({ stored: 160 });
await settled();
await p.evaluate(() => {
const wide = document.createElement('div');
wide.id = 'lateReading';
wide.style.cssText = 'width:320px;flex:none;height:24px';
document.getElementById('headerRight')!.appendChild(wide);
});
await settled();
const { edge, lefts } = await readingsInside();
for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge);
expect((await box('#headerRight')).width).toBeGreaterThanOrEqual(320);
await p.close();
});
it('sheds labels, then provider names, then percentages as the toolbar narrows', async () => {
await open({ chip: true });
expect(await display('.pu-label')).not.toBe('none');
const seen: string[] = [];
for (const width of [450, 330, 200]) {
await dragTo(width);
seen.push((await p.evaluate(() => document.documentElement.dataset.headerDensity)) ?? '');
}
expect(seen).toEqual(['tight', 'dense', 'tiny']);
expect(await display('.pu-label')).toBe('none');
expect(await display('.pu-val')).toBe('none');
expect(
await p
.locator('.pu-provider')
.first()
.evaluate((el) => getComputedStyle(el, '::after').content)
).toBe('"CL"');
await p.close();
});
for (const style of ['tiles', 'classic']) {
it(`follows the density steps under the ${style} style, keeping values at the narrowest step`, async () => {
await open({ chip: true, root: `data-header-stats="${style}"` });
await dragTo(330);
expect(
await p
.locator('.pu-provider')
.first()
.evaluate((el) => getComputedStyle(el).fontSize)
).toBe('0px');
await dragTo(200);
expect(await display('.pu-val')).not.toBe('none');
await p.close();
});
}
for (const [name, root, body] of [
['the sidebar layout', 'data-session-list="sidebar"', ''],
['the vertical rail', 'data-tab-orientation="vertical"', ''],
['a solo window', '', 'solo-mode'],
] as const) {
it(`leaves a stored narrow width alone in ${name}, where the handle is hidden`, async () => {
await open({ chip: true, stored: 180, root, body });
// the stored width is read at init, which already ran inside open() before storage was set
await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '180'));
await p.reload();
await p.addScriptTag({ content: controller });
await settled();
expect(await p.locator('.header-divider').isVisible()).toBe(false);
expect(await display('.stat-label')).not.toBe('none');
expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('180px');
await p.close();
});
}
it('does not pin the toolbar to a stored width on a narrow viewport', async () => {
await open({ chip: true, width: 700 });
await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '300'));
await p.reload();
await p.addScriptTag({ content: controller });
await settled();
expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('300px');
await p.close();
});
it('gets the chosen width back when the window shrinks and grows again', async () => {
await open({ chip: true });
await dragTo(700);
const chosen = (await box('#headerRight')).width;
await p.setViewportSize({ width: 1000, height: 600 });
await settled();
expect((await box('#headerRight')).width).toBeLessThan(chosen);
await p.setViewportSize({ width: 1400, height: 600 });
await settled();
expect(Math.abs((await box('#headerRight')).width - chosen)).toBeLessThan(2);
await p.close();
});
it('clears the dragging state when the pointer capture is lost', async () => {
await open({ chip: true });
const handle = await box('.header-divider');
await p.mouse.move(handle.left + 6, handle.top + 10);
await p.mouse.down();
expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(true);
await p.locator('.header-divider').evaluate((el) => el.dispatchEvent(new PointerEvent('lostpointercapture')));
expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(false);
await p.mouse.up();
await p.close();
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* @fileoverview The draggable divider at the left edge of the header toolbar: the width clamp keeps the tabs
* some room, the controller is wired into the page and build, and the stored width is device-local.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8');
function load() {
const window: Record<string, unknown> = {};
const context = vm.createContext({
window,
document: { readyState: 'loading', addEventListener: () => {}, querySelector: () => null },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(read('header-divider.js'), context, { filename: 'header-divider.js' });
return window.CodemanHeaderDivider as {
clampWidth: (w: number, header: number, brand: number) => number;
densityFor: (w: number) => string;
MIN_WIDTH: number;
MIN_TABS_ROOM: number;
};
}
describe('header divider', () => {
it('clamps the toolbar between its minimum and what leaves the tabs room', () => {
const { clampWidth, MIN_WIDTH, MIN_TABS_ROOM } = load();
expect(clampWidth(10, 1600, 120)).toBe(MIN_WIDTH);
expect(clampWidth(5000, 1600, 120)).toBe(1600 - 120 - MIN_TABS_ROOM);
expect(clampWidth(420.4, 1600, 120)).toBe(420);
});
it('shows less of the usage readings the narrower the toolbar gets', () => {
const { densityFor } = load();
expect([700, 560, 559, 400, 399, 280, 279, 160].map(densityFor)).toEqual([
'',
'',
'tight',
'tight',
'dense',
'dense',
'tiny',
'tiny',
]);
});
it('never goes below the minimum even in a header too narrow for the tabs room', () => {
const { clampWidth, MIN_WIDTH } = load();
expect(clampWidth(400, 300, 100)).toBe(MIN_WIDTH);
});
it('is wired into the page and the build, and hides where the tabs are not in the header', () => {
expect(read('index.html')).toContain('<script defer src="header-divider.js"></script>');
expect(buildJs).toContain("run('minify header-divider.js'");
expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1500}'header-divider\.js'/);
const css = read('styles.css');
expect(css).toContain('.header-divider');
expect(css).toMatch(/data-session-list='sidebar'\] \.header-divider/);
expect(css).toMatch(/data-tab-orientation='vertical'\] \.header-divider/);
});
it('keeps the width on the device only and offers a keyboard reset', () => {
const js = read('header-divider.js');
expect(js).toContain('localStorage');
expect(js).not.toContain('/api/settings');
expect(js).toMatch(/key === 'Home'/);
// Escape belongs to the global close-panels handler (app.js), so the handle must not claim it too.
expect(js).not.toMatch(/'Escape'/);
expect(js).toContain("addEventListener('dblclick'");
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview The opt-in header readings (GPU, VRAM, GPU MHz, CPU MHz): all off by default, requested from
* the server only while their setting is on, and shown only when the host reported them.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
const read = (name: string) => readFileSync(join(process.cwd(), 'src/web/public', name), 'utf-8');
type El = {
hidden: boolean;
textContent: string;
style: Record<string, string>;
classList: ReturnType<typeof classList>;
};
const classList = () => {
const set = new Set<string>();
return {
set,
add: (c: string) => set.add(c),
remove: (c: string) => set.delete(c),
toggle: (c: string, force?: boolean) => {
const on = force ?? !set.has(c);
if (on) set.add(c);
else set.delete(c);
return on;
},
};
};
function harness(settings: Record<string, unknown>, options: { hold?: boolean } = {}) {
const els = new Map<string, El>();
const fetched: string[] = [];
let release: () => void = () => undefined;
const gate = options.hold ? new Promise<void>((resolve) => (release = resolve)) : null;
class CodemanApp {}
const context = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => ({ style: {} }) },
fetch: async (url: string) => {
fetched.push(url);
if (gate) await gate;
return { json: async () => ({ data: { cpu: 1, memory: { usedMB: 1, totalMB: 2, percent: 50 } } }) };
},
localStorage: { getItem: () => null },
setInterval: () => 0,
clearInterval: () => {},
console,
});
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const app = new (context.CodemanApp as new () => Record<string, (...a: unknown[]) => unknown>)();
Object.assign(app, {
loadAppSettingsFromStorage: () => settings,
$: (id: string) => {
if (!els.has(id))
els.set(id, {
hidden: true,
textContent: '',
style: { setProperty: () => {} } as unknown as Record<string, string>,
classList: classList(),
});
return els.get(id);
},
});
return { app, els, fetched, release };
}
describe('header host sensors', () => {
it('requests nothing extra from the server when every setting is off (the default)', async () => {
const { app, fetched } = harness({});
await app.fetchSystemStats.call(app);
expect(fetched).toEqual(['/api/system/stats']);
});
it('asks for exactly the readings whose setting is on', async () => {
const { app, fetched } = harness({ showGpuStats: true, showCpuMhz: true });
await app.fetchSystemStats.call(app);
expect(fetched).toEqual(['/api/system/stats?gpu=1&cpumhz=1']);
});
it('shows an item only when its setting is on and the host reported it', () => {
const stats = {
gpu: { percent: 42, memPercent: 50, memUsedMB: 4096, memTotalMB: 8192, mhz: 1800 },
cpuMhz: 3400,
};
const on = harness({ showGpuStats: true, showCpuMhz: true });
on.app._updateHostSensors(stats);
for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) {
expect(on.els.get(id)?.hidden, id).toBe(false);
}
expect(on.els.get('statGpu')?.textContent).toBe('42%');
expect(on.els.get('statVram')?.textContent).toBe('4.0G');
expect(on.els.get('statCpuMhz')?.textContent).toBe('3400 MHz');
expect(on.els.get('statGpuMhz')?.textContent).toBe('1800 MHz');
const off = harness({});
off.app._updateHostSensors(stats);
for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) {
expect(off.els.get(id)?.hidden, id).toBe(true);
}
// the GPU clock belongs to GPU Stats; with only CPU MHz on it stays hidden
const cpuOnly = harness({ showCpuMhz: true });
cpuOnly.app._updateHostSensors(stats);
expect(cpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(false);
expect(cpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(true);
const gpuOnly = harness({ showGpuStats: true });
gpuOnly.app._updateHostSensors(stats);
expect(gpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(false);
expect(gpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(true);
const noHardware = harness({ showGpuStats: true, showCpuMhz: true });
noHardware.app._updateHostSensors({});
expect(noHardware.els.get('statGpuItem')?.hidden).toBe(true);
});
it('shows graphics memory in use even when its total is unknown, with no ring', () => {
const { app, els } = harness({ showGpuStats: true });
app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536 } });
expect(els.get('statVramItem')?.hidden).toBe(false);
expect(els.get('statVram')?.textContent).toBe('1.5G');
expect(els.get('statVramRing')?.style.display).toBe('none');
// marked ringless, so the Compact style's rings-only step hides the item rather than leave an empty one
expect(els.get('statVramItem')?.classList.set.has('stat-item--ringless')).toBe(true);
app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536, memTotalMB: 8192, memPercent: 19 } });
expect(els.get('statVramRing')?.style.display).toBe('');
expect(els.get('statVramItem')?.classList.set.has('stat-item--ringless')).toBe(false);
});
it('keeps polling the header stats with no session open, so CPU, MEM and the readings are never stuck at --', () => {
const appJs = read('app.js');
expect(appJs).toContain('if (!this.systemStatsInterval) this.startSystemStatsPolling();');
expect(appJs).not.toMatch(/sessions\.size === 0\) this\.stopSystemStatsPolling\(\)/);
expect(appJs).not.toMatch(/\} else \{\s*this\.stopSystemStatsPolling\(\);/);
});
it('runs one stats request at a time, so a slow answer cannot pile requests up behind it', async () => {
const { app, fetched, release } = harness({}, { hold: true });
const polls = [app.fetchSystemStats.call(app), app.fetchSystemStats.call(app), app.fetchSystemStats.call(app)];
expect(fetched).toHaveLength(1);
release();
await Promise.all(polls);
// answered: the next poll goes out again
await app.fetchSystemStats.call(app);
expect(fetched).toHaveLength(2);
});
it('is opt-in in the settings page: unchecked chips, off in every default, stripped from the settings PUT', () => {
const html = read('index.html');
for (const id of ['appSettingsShowGpuStats', 'appSettingsShowCpuMhz']) {
expect(html).toMatch(new RegExp(`<input type="checkbox" id="${id}">`));
}
const settingsUi = read('settings-ui.js');
expect(settingsUi).toContain('showGpuStats ?? defaults.showGpuStats ?? false');
expect(settingsUi).toContain('showCpuMhz ?? defaults.showCpuMhz ?? false');
// per-device, so stripped from the PUT body like the other header toggles (the schema is .strict())
expect(settingsUi).toContain('showGpuStats: _sgp,');
expect(settingsUi).toContain('showCpuMhz: _scm,');
const schemas = readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8');
expect(schemas).not.toContain('showCpuMhz');
expect(schemas).not.toContain('showGpuStats');
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Real Chromium, real header markup and styles.css: the opt-in host readings stay out of the header until they
* are switched on and reported, under every header-stats style, and a reported one is drawn with its value.
* As the header divider narrows the toolbar (data-header-density), they keep the label that says which reading
* is which, and the Compact style's rings-only step hides a reading with no ring instead of leaving it empty.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const dir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8');
const header = /<header class="header">[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0];
const ITEMS = ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem'];
/** Each host reading: its value element, a value that looks like CPU's or MEM's, and the label naming it. */
const READINGS: Record<string, { value: string; text: string; label: string }> = {
statGpuItem: { value: 'statGpu', text: '40%', label: 'GPU' },
statVramItem: { value: 'statVram', text: '3.2G', label: 'VRAM' },
statGpuMhzItem: { value: 'statGpuMhz', text: '1800 MHz', label: 'GPU' },
statCpuMhzItem: { value: 'statCpuMhz', text: '4200 MHz', label: 'CPU' },
};
const RINGLESS = ['statGpuMhzItem', 'statCpuMhzItem'];
describe('opt-in host readings in the header (real Chromium)', () => {
let browser: Browser;
let p: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
for (const style of ['compact', 'tiles', 'classic']) {
it(`draws none of them until reported, and the reported one with its value, in the ${style} style`, async () => {
p = await browser.newPage({ viewport: { width: 1500, height: 400 } });
await p.setContent(
`<!doctype html><html data-header-stats="${style}" data-tab-orientation="horizontal" data-session-list="header"><head><style>${styles}</style></head><body>${header}</body></html>`
);
const shown = () =>
p.evaluate(
(ids) => ids.filter((id) => getComputedStyle(document.getElementById(id)!).display !== 'none'),
ITEMS
);
expect(await shown()).toEqual([]);
await p.evaluate(() => {
document.getElementById('statGpuItem')!.hidden = false;
document.getElementById('statGpu')!.textContent = '42%';
});
expect(await shown()).toEqual(['statGpuItem']);
const box = await p.locator('#statGpuItem').boundingBox();
expect(box!.width).toBeGreaterThan(20);
expect(await p.locator('#statGpuItem').innerText()).toContain('42%');
await p.close();
});
it(`keeps every host reading's label as the toolbar narrows, in the ${style} style`, async () => {
p = await browser.newPage({ viewport: { width: 1600, height: 400 } });
await p.setContent(
`<!doctype html><html data-header-stats="${style}" data-tab-orientation="horizontal" data-session-list="header"><head><style>${styles}</style></head><body>${header}</body></html>`
);
await p.evaluate((readings) => {
document.getElementById('statCpu')!.textContent = '12%';
document.getElementById('statMem')!.textContent = '7.9G';
for (const [id, r] of Object.entries(readings)) {
document.getElementById(id)!.hidden = false;
document.getElementById(r.value)!.textContent = r.text;
}
}, READINGS);
const read = () =>
p.evaluate((ids) => {
const shown = (el: Element) => getComputedStyle(el).display !== 'none';
return Object.fromEntries(
ids.map((id) => {
const item = document.getElementById(id)!;
const label = item.querySelector('.stat-label')!;
return [
id,
{
shown: shown(item),
width: item.getBoundingClientRect().width,
label: shown(label) && label.getBoundingClientRect().width > 0 ? label.textContent : null,
},
];
})
);
}, ITEMS);
const cpuLabel = () =>
p.evaluate(() => getComputedStyle(document.querySelector('.header-system-stats .stat-label')!).display);
for (const density of ['tight', 'dense', 'tiny']) {
await p.evaluate((d) => (document.documentElement.dataset.headerDensity = d), density);
const items = await read();
// the density step still sheds CPU's and MEM's labels
expect(await cpuLabel(), density).toBe('none');
for (const id of ITEMS) {
if (style === 'compact' && density === 'tiny' && RINGLESS.includes(id)) {
// rings only: a clock has no ring, so it goes rather than stay an empty item taking a gap
expect(items[id].shown, `${density} ${id}`).toBe(false);
continue;
}
expect(items[id].shown, `${density} ${id}`).toBe(true);
expect(items[id].width, `${density} ${id}`).toBeGreaterThan(0);
expect(items[id].label, `${density} ${id}`).toBe(READINGS[id].label);
}
}
// VRAM whose total is unknown has no ring either (panels-ui.js marks it ringless)
await p.evaluate(() => {
document.getElementById('statVramItem')!.classList.add('stat-item--ringless');
document.getElementById('statVramRing')!.style.display = 'none';
});
expect((await read()).statVramItem.shown).toBe(style !== 'compact');
await p.close();
});
}
});
+250
View File
@@ -0,0 +1,250 @@
/**
* The order and the back-offs of readHostSensors, with `child_process` and the sysfs reads mocked so no
* hardware is needed: native GPU readers go first (a machine with a working nvidia-smi never starts the
* Windows GPU counters), a failing reader is left alone for a minute, neither a cold Windows sampler nor
* the PowerShell RAM probe is waited for, and the GPU counters leave the sampler again once nothing needs them.
*/
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type ExecCall = { cmd: string; args: string[] };
const state = vi.hoisted(() => ({
nvidia: '' as string | Error,
powershell: '' as string | Error,
/** When set, PowerShell does not answer until the test calls the callbacks kept here. */
holdPowershell: false,
heldPowershell: [] as Array<() => void>,
/** When set, nvidia-smi never answers (a child stuck on a hung driver) until the test calls these. */
holdNvidia: false,
heldNvidia: [] as Array<() => void>,
execCalls: [] as Array<{ cmd: string; args: string[] }>,
spawnCalls: [] as Array<{ cmd: string; args: string[]; stdout: unknown }>,
}));
vi.mock('node:child_process', () => ({
execFile: (cmd: string, args: string[], _opts: unknown, cb: (err: Error | null, stdout: string) => void) => {
state.execCalls.push({ cmd, args });
if (state.holdNvidia && cmd === 'nvidia-smi') {
state.heldNvidia.push(() => cb(null, NVIDIA));
return;
}
if (state.holdPowershell && cmd.endsWith('powershell.exe')) {
state.heldPowershell.push(() => {
const held = state.powershell;
if (held instanceof Error) cb(held, '');
else cb(null, held);
});
return;
}
const answer = cmd === 'nvidia-smi' ? state.nvidia : cmd.endsWith('powershell.exe') ? state.powershell : '';
if (answer instanceof Error) cb(answer, '');
else cb(null, answer);
},
spawn: (cmd: string, args: string[]) => {
const stdout = new PassThrough();
const child = Object.assign(new EventEmitter(), { stdout, kill: () => true });
state.spawnCalls.push({ cmd, args, stdout });
return child;
},
}));
vi.mock('node:fs/promises', () => ({
readdir: async () => [],
readFile: async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
},
}));
const NVIDIA = '37, 2048, 8192, 1815\n';
const HEADER =
'"(PDH-CSV 4.0)",' +
'"\\\\H\\GPU Engine(pid_1_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage",' +
'"\\\\H\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Shared Usage",' +
'"\\\\H\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Dedicated Usage",' +
'"\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n';
const ROW = '"t","20","1073741824","0","1650"\r\n';
async function load() {
vi.resetModules();
return import('../src/host-sensors.js');
}
const counted = (cmd: string) => state.execCalls.filter((c: ExecCall) => c.cmd.endsWith(cmd)).length;
const tick = () => new Promise((r) => setImmediate(r));
beforeEach(() => {
state.nvidia = NVIDIA;
state.powershell = new Error('powershell failed');
state.holdPowershell = false;
state.heldPowershell = [];
state.holdNvidia = false;
state.heldNvidia = [];
state.execCalls = [];
state.spawnCalls = [];
process.env.WSL_INTEROP = '1';
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-24T00:00:00Z'));
});
afterEach(() => {
delete process.env.WSL_INTEROP;
vi.useRealTimers();
});
describe('native readers first', () => {
it('answers from nvidia-smi on WSL and never starts the Windows GPU counters', async () => {
const { readHostSensors, stopHostSensors } = await load();
const out = await readHostSensors({ gpu: true });
expect(out.gpu).toMatchObject({ percent: 37, memUsedMB: 2048, memPercent: 25, mhz: 1815 });
expect(state.spawnCalls).toEqual([]);
stopHostSensors();
});
it('starts only the CPU clock counter for a CPU MHz request, and never waits for it', async () => {
const { readHostSensors, stopHostSensors } = await load();
const started = Date.now();
const out = await readHostSensors({ cpuMhz: true });
expect(Date.now() - started).toBeLessThan(1000);
expect(out).toEqual({});
expect(state.spawnCalls).toHaveLength(1);
expect(state.spawnCalls[0].args.join(' ')).toContain('Processor Frequency');
expect(state.spawnCalls[0].args.join(' ')).not.toContain('GPU Engine');
stopHostSensors();
});
it('falls back to the Windows counters, GPU ones included, when nvidia-smi gives nothing', async () => {
state.nvidia = new Error('nvidia-smi: command not found');
const { readHostSensors, stopHostSensors } = await load();
await readHostSensors({ gpu: true });
expect(state.spawnCalls).toHaveLength(1);
expect(state.spawnCalls[0].args.join(' ')).toContain('GPU Engine');
stopHostSensors();
});
});
describe('a failing reader is left alone for a minute', () => {
it('does not run nvidia-smi on every poll of a host without one, and tries again after the back-off', async () => {
state.nvidia = new Error('nvidia-smi: command not found');
const { readHostSensors, stopHostSensors } = await load();
for (let i = 0; i < 5; i++) {
await readHostSensors({ gpu: true });
vi.setSystemTime(Date.now() + 2500); // past the 2 s cache each time
}
expect(counted('nvidia-smi')).toBe(1);
vi.setSystemTime(Date.now() + 61_000);
await readHostSensors({ gpu: true });
expect(counted('nvidia-smi')).toBe(2);
stopHostSensors();
});
it('does not retry a failing PowerShell on every GPU poll', async () => {
state.nvidia = new Error('no nvidia');
const { readHostSensors, stopHostSensors } = await load();
await readHostSensors({ gpu: true }); // starts the sampler
(state.spawnCalls[0].stdout as PassThrough).write(HEADER + ROW);
await tick();
for (let i = 0; i < 4; i++) {
vi.setSystemTime(Date.now() + 2500);
// the real sampler emits a row every 2 s
(state.spawnCalls[0].stdout as PassThrough).write(ROW);
await tick();
const out = await readHostSensors({ gpu: true });
expect(out.gpu).toMatchObject({ percent: 20, memUsedMB: 1024 });
expect(out.gpu?.memPercent).toBeUndefined(); // no RAM total without PowerShell
}
expect(counted('powershell.exe')).toBe(1);
vi.setSystemTime(Date.now() + 61_000);
(state.spawnCalls[0].stdout as PassThrough).write(ROW);
await tick();
await readHostSensors({ gpu: true });
expect(counted('powershell.exe')).toBe(2);
stopHostSensors();
});
});
describe('a hung nvidia-smi', () => {
it('answers at its own deadline instead of waiting for a child that never exits, then backs off', async () => {
state.holdNvidia = true;
const { readHostSensors, stopHostSensors } = await load();
const started = performance.now();
// execFile's timeout only signals the child; one that never exits never calls back
const first = await Promise.race([
readHostSensors({ gpu: true }),
new Promise<'hung'>((r) => setTimeout(() => r('hung'), 4000)),
]);
expect(first).not.toBe('hung');
expect(performance.now() - started).toBeLessThan(3500);
expect((first as { gpu?: object }).gpu).toBeUndefined();
expect(counted('nvidia-smi')).toBe(1);
// the reader that timed out is left alone like a failing one: the next poll does not start a second
vi.setSystemTime(Date.now() + 2500);
await readHostSensors({ gpu: true });
expect(counted('nvidia-smi')).toBe(1);
// the child finally exiting changes nothing
state.heldNvidia.forEach((answer) => answer());
await tick();
expect(counted('nvidia-smi')).toBe(1);
stopHostSensors();
});
});
describe('the PowerShell RAM probe', () => {
it('is never waited for: the memory in use shows at once, and the total once Windows has answered', async () => {
state.nvidia = new Error('no nvidia');
state.holdPowershell = true;
const { readHostSensors, stopHostSensors } = await load();
await readHostSensors({ gpu: true }); // starts the sampler
(state.spawnCalls[0].stdout as PassThrough).write(HEADER + ROW);
await tick();
vi.setSystemTime(Date.now() + 2500);
// PowerShell has not answered (and never will unless the test lets it): the read must not wait for it
const first = await Promise.race([
readHostSensors({ gpu: true }),
new Promise<'waited'>((r) => setTimeout(() => r('waited'), 1000)),
]);
expect(first).not.toBe('waited');
expect((first as { gpu?: object }).gpu).toEqual({ percent: 20, memUsedMB: 1024 });
expect(counted('powershell.exe')).toBe(1);
// a poll while the probe is still running starts no second PowerShell
vi.setSystemTime(Date.now() + 2500);
(state.spawnCalls[0].stdout as PassThrough).write(ROW);
await tick();
expect((await readHostSensors({ gpu: true })).gpu?.memTotalMB).toBeUndefined();
expect(counted('powershell.exe')).toBe(1);
// Windows answers 16 GiB: an integrated GPU may use half of it
state.powershell = '17179869184\r\n';
state.heldPowershell.forEach((answer) => answer());
await tick();
for (let i = 0; i < 2; i++) {
vi.setSystemTime(Date.now() + 2500);
(state.spawnCalls[0].stdout as PassThrough).write(ROW);
await tick();
const out = await readHostSensors({ gpu: true });
expect(out.gpu).toEqual({ percent: 20, memUsedMB: 1024, memTotalMB: 8192, memPercent: 13 });
}
// the answer is kept: PowerShell is not asked again
expect(counted('powershell.exe')).toBe(1);
stopHostSensors();
});
});
describe('the GPU counters do not stay in the sampler for good', () => {
it('holds them for a while after the last read that needed them, then drops them', async () => {
const { createWindowsSource, windowsCounterList } = await load();
let clock = 1_000;
const started: string[][] = [];
const stream = {
read: async (counters: string[]) => {
started.push(counters);
return undefined;
},
stop: () => undefined,
};
const source = createWindowsSource(stream, () => clock);
await source.read();
source.needGpuCounters();
clock += 60_000;
await source.read(); // still inside the hold
clock += 61_000; // 121 s after the last need
await source.read();
expect(started).toEqual([windowsCounterList(false), windowsCounterList(true), windowsCounterList(false)]);
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* @fileoverview The pure parsers behind the opt-in header readings (nvidia-smi, the amdgpu clock table,
* cpufreq and /proc/cpuinfo, typeperf), readHostSensors reading nothing unless a reading is asked for, the
* typeperf counter stream's life cycle (first sample, recycling, idle stop, back-off after a failed start,
* no wait on a cold sampler by default, a late row from a replaced sampler), and the Windows sampler that
* every browser shares. No hardware needed.
*/
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import {
createCounterStream,
createWindowsSource,
windowsCounterList,
meanKhzAsMhz,
parseCpuInfoMhz,
parseDpmClock,
parseNvidiaSmi,
parseTypeperf,
readHostSensors,
} from '../src/host-sensors.js';
describe('parseNvidiaSmi', () => {
it('reads load, memory and clock from the first GPU', () => {
expect(parseNvidiaSmi('37, 2048, 8192, 1815\n12, 1, 8192, 210\n')).toEqual({
percent: 37,
memUsedMB: 2048,
memTotalMB: 8192,
memPercent: 25,
mhz: 1815,
});
});
it('keeps the load when the driver reports no memory or clock, and refuses non-numbers', () => {
expect(parseNvidiaSmi('5, [N/A], [N/A], [N/A]')).toEqual({ percent: 5 });
expect(parseNvidiaSmi('[N/A], 1, 2, 3')).toBeUndefined();
expect(parseNvidiaSmi('')).toBeUndefined();
});
it('clamps a load above 100', () => {
expect(parseNvidiaSmi('140, 1, 2, 3')?.percent).toBe(100);
});
});
describe('clock parsers', () => {
it('takes the active line of an amdgpu pp_dpm_sclk table', () => {
expect(parseDpmClock('0: 500Mhz\n1: 1200Mhz *\n2: 2100Mhz\n')).toBe(1200);
expect(parseDpmClock('0: 500Mhz\n')).toBeUndefined();
});
it('averages per-core kHz readings into MHz and ignores gaps', () => {
expect(meanKhzAsMhz([3000000, 4000000, Number.NaN, 0])).toBe(3500);
expect(meanKhzAsMhz([])).toBeUndefined();
});
it('averages the cpu MHz lines of /proc/cpuinfo', () => {
expect(parseCpuInfoMhz('processor : 0\ncpu MHz\t\t: 3000.500\nprocessor : 1\ncpu MHz\t\t: 4000.500\n')).toBe(3501);
expect(parseCpuInfoMhz('model name : x\n')).toBeUndefined();
});
});
describe('parseTypeperf', () => {
const header = [
'(PDH-CSV 4.0)',
'\\\\DAEDALUS\\GPU Engine(pid_10728_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Engine(pid_2016_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Engine(pid_2016_luid_0x00000000_0x000139EC_phys_0_eng_1_engtype_VideoDecode)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Engine(pid_9_luid_0x00000000_0x00013DF2_phys_0_eng_0_engtype_3D)\\Utilization Percentage',
'\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Shared Usage',
'\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Dedicated Usage',
'\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x00013DF2_phys_0)\\Shared Usage',
'\\\\DAEDALUS\\Processor Information(_Total)\\Processor Frequency',
];
const sample = (values: string[]) =>
`\r\n"${header.join('","')}"\r\n"${values.join('","')}"\r\nExiting, please wait... The command completed successfully.\r\n`;
it('takes the busiest engine type of the adapter holding the most memory, and the clock', () => {
const out = parseTypeperf(
sample(['10/23/2026 21:00:00.123', '12.5', '30', '3.5', '99', '1015808000', '0', '8192', '1640'])
);
// 3D engines sum to 42.5 (the video decode engine is lower), and the software adapter's 99 is ignored
expect(out.gpu).toEqual({ percent: 43, memUsedMB: 969, dedicated: false });
expect(out.cpuMhz).toBe(1640);
});
it('caps the load at 100 and marks an adapter with dedicated memory', () => {
const out = parseTypeperf(sample(['t', '80', '70', '0', '0', '0', '2147483648', '0', '1000']));
expect(out.gpu).toEqual({ percent: 100, memUsedMB: 2048, dedicated: true });
});
it('skips counters with no value and answers nothing for unusable output', () => {
const out = parseTypeperf(sample(['t', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']));
expect(out.cpuMhz).toBeUndefined();
expect(parseTypeperf('')).toEqual({});
expect(parseTypeperf('The specified counter could not be found.')).toEqual({});
});
});
describe('readHostSensors', () => {
it('reads nothing at all unless a reading is asked for', async () => {
expect(await readHostSensors({})).toEqual({});
expect(await readHostSensors({ gpu: false, cpuMhz: false })).toEqual({});
});
});
describe('createCounterStream', () => {
const ROW = (a: string) => `"t","${a}"\r\n`;
const HEADER = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n';
function fake() {
const children: Array<{ stdout: PassThrough; killed: boolean; emitter: EventEmitter }> = [];
let clock = 1000;
const stream = createCounterStream({
start: () => {
const stdout = new PassThrough();
const emitter = new EventEmitter();
const child = { stdout, killed: false, emitter };
children.push(child);
return {
stdout,
kill: () => {
child.killed = true;
return true;
},
once: (event: string, cb: () => void) => emitter.once(event, cb),
} as never;
},
now: () => clock,
firstSampleMs: 50,
recycleMs: 30000,
idleMs: 30000,
backoffMs: 60000,
});
return { children, stream, advance: (ms: number) => (clock += ms) };
}
const counters = ['\\Processor Information(_Total)\\Processor Frequency'];
it('waits for the first sample, then hands out the newest without starting another sampler', async () => {
const { children, stream } = fake();
const first = stream.read(counters);
children[0].stdout.write('\r\n' + HEADER + ROW('1700'));
expect(await first).toEqual({ cpuMhz: 1700 });
children[0].stdout.write(ROW('1900'));
await new Promise((r) => setImmediate(r));
expect(await stream.read(counters)).toEqual({ cpuMhz: 1900 });
expect(children).toHaveLength(1);
});
it('replaces an old sampler so new GPU processes are counted, and stops an idle one', async () => {
const { children, stream, advance } = fake();
const first = stream.read(counters);
children[0].stdout.write(HEADER + ROW('1700'));
await first;
advance(31000);
const again = stream.read(counters);
expect(children).toHaveLength(2);
expect(children[0].killed).toBe(true);
children[1].stdout.write(HEADER + ROW('1800'));
expect(await again).toEqual({ cpuMhz: 1800 });
advance(31000);
stream.sweep();
expect(children[1].killed).toBe(true);
});
it('gives up quietly, and waits before trying again, when the sampler ends without a sample', async () => {
const { children, stream, advance } = fake();
const first = stream.read(counters);
children[0].emitter.emit('exit');
expect(await first).toBeUndefined();
expect(await stream.read(counters)).toBeUndefined();
expect(children).toHaveLength(1);
advance(61000);
void stream.read(counters);
expect(children).toHaveLength(2);
});
it('does not wait at all by default: a cold sampler answers nothing and the next read has the sample', async () => {
const children: PassThrough[] = [];
const stream = createCounterStream({
start: () => {
const stdout = new PassThrough();
children.push(stdout);
return { stdout, kill: () => true, once: () => undefined } as never;
},
});
const counters = ['\\Processor Information(_Total)\\Processor Frequency'];
const header = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n';
const started = Date.now();
expect(await stream.read(counters)).toBeUndefined();
expect(Date.now() - started).toBeLessThan(500);
children[0].write(header + '"t","1700"\r\n');
await new Promise((r) => setImmediate(r));
expect(await stream.read(counters)).toEqual({ cpuMhz: 1700 });
expect(children).toHaveLength(1);
});
it('answers nothing, within the wait, when no sample ever arrives', async () => {
const { stream } = fake();
expect(await stream.read(counters)).toBeUndefined();
});
});
describe('the Windows sampler shared by every browser', () => {
const HEADER = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n';
function rig() {
const children: Array<{ stdout: PassThrough; killed: boolean; counters: string[] }> = [];
const stream = createCounterStream({
start: (counters) => {
const stdout = new PassThrough();
const child = { stdout, killed: false, counters };
children.push(child);
return {
stdout,
kill: () => {
child.killed = true;
return true;
},
once: () => undefined,
} as never;
},
firstSampleMs: 20,
});
return { children, source: createWindowsSource(stream) };
}
it('samples the same counters whatever a browser asked for, so two browsers share one process', async () => {
const { children, source } = rig();
// browser A (GPU Stats) and browser B (GPU Stats and CPU MHz) read the same sampler in turn
for (let i = 0; i < 8; i++) {
const read = source.read();
if (i === 0) children[0].stdout.write(HEADER + '"t","1700"\r\n');
await read;
}
expect(children).toHaveLength(1);
expect(children[0].killed).toBe(false);
});
it('adds the GPU counters with one restart on a machine with no native GPU reader, and keeps them while needed', async () => {
const { children, source } = rig();
void source.read();
expect(children[0].counters).toEqual(windowsCounterList(false));
expect(children[0].counters.some((c) => c.includes('GPU Engine'))).toBe(false);
source.needGpuCounters();
void source.read();
expect(children).toHaveLength(2);
expect(children[0].killed).toBe(true);
expect(children[1].counters).toEqual(windowsCounterList(true));
source.needGpuCounters();
void source.read();
void source.read();
expect(children).toHaveLength(2);
});
});
describe('a late row from a replaced sampler', () => {
it("is ignored, so it can never become the new sampler's header", async () => {
const children: PassThrough[] = [];
let clock = 1000;
const stream = createCounterStream({
start: () => {
const stdout = new PassThrough();
children.push(stdout);
return { stdout, kill: () => true, once: () => undefined } as never;
},
now: () => clock,
firstSampleMs: 20,
recycleMs: 30000,
});
const counters = ['\\Processor Information(_Total)\\Processor Frequency'];
const header = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n';
const first = stream.read(counters);
children[0].write(header + '"t","1700"\r\n');
await first;
clock += 31000;
const second = stream.read(counters); // replaces the sampler
expect(children).toHaveLength(2);
// the killed process still had a data row in its pipe: it arrives after the replacement began
children[0].write('"t","999"\r\n');
await new Promise((r) => setImmediate(r));
// the stale row is not a sample: with nothing from the new process yet there is still nothing to hand out
expect(await stream.read(counters)).toBeUndefined();
children[1].write(header + '"t","1800"\r\n');
await second;
await new Promise((r) => setImmediate(r));
expect(await stream.read(counters)).toEqual({ cpuMhz: 1800 });
});
});
+4 -4
View File
@@ -373,11 +373,11 @@ describe('Tab Navigation', () => {
var origPrev = app.prevSession;
app.nextSession = function() {
window.__swipeLog.push('next');
if (origNext) origNext.call(app);
if (origNext) origNext.apply(app, arguments);
};
app.prevSession = function() {
window.__swipeLog.push('prev');
if (origPrev) origPrev.call(app);
if (origPrev) origPrev.apply(app, arguments);
};
}
`);
@@ -482,7 +482,7 @@ describe('Tab Navigation', () => {
var origNext = app.nextSession;
app.nextSession = function() {
window.__swipeLog.push('next');
if (origNext) origNext.call(app);
if (origNext) origNext.apply(app, arguments);
};
}
`);
@@ -520,7 +520,7 @@ describe('Tab Navigation', () => {
var origPrev = app.prevSession;
app.prevSession = function() {
window.__swipeLog.push('prev');
if (origPrev) origPrev.call(app);
if (origPrev) origPrev.apply(app, arguments);
};
}
`);
+245 -6
View File
@@ -16,11 +16,21 @@ import path from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
import { createUser, invalidateUsersCache, verifyPassword } from '../src/user-store.js';
import type { FastifyInstance } from 'fastify';
import { AUTH_FAILURE_MAX, AUTH_IP_FAILURE_MAX } from '../src/config/auth-config.js';
import type { StaleExpirationMap } from '../src/utils/stale-expiration-map.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
// Count scrypt verifies: the real verifyPassword, wrapped, so the middleware still checks
// the real store and a test can tell how many requests reached it.
vi.mock('../src/user-store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/user-store.js')>();
return { ...actual, verifyPassword: vi.fn(actual.verifyPassword) };
});
const verifyCalls = vi.mocked(verifyPassword);
function basic(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
@@ -33,6 +43,8 @@ function cookieFrom(res: Response): string | null {
let server: WebServer;
let rateServer: WebServer;
let sprayServer: WebServer;
const ipServers: WebServer[] = [];
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
@@ -72,6 +84,8 @@ beforeAll(async () => {
afterAll(async () => {
await server?.stop();
await rateServer?.stop().catch(() => {});
await sprayServer?.stop().catch(() => {});
for (const s of ipServers) await s.stop().catch(() => {});
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
@@ -83,6 +97,24 @@ afterAll(async () => {
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
/** The server's private failure buckets, read to count how many guesses reached the verify. */
type FailureBuckets = Record<'authFailures' | 'userFailures' | 'userIpFailures', StaleExpirationMap<string, number>>;
/**
* A fresh multi-user server (empty failure buckets) and a login helper that sets the
* client IP through inject's `remoteAddress`: a real fetch always arrives from
* loopback, so it cannot show two sources.
*/
async function startIpServer() {
const s = new WebServer(0, false, true);
await s.start();
ipServers.push(s);
const app = (s as unknown as { app: FastifyInstance }).app;
const login = (ip: string, user: string, pass: string) =>
app.inject({ method: 'GET', url: '/api/status', headers: { authorization: basic(user, pass) }, remoteAddress: ip });
return { login, buckets: s as unknown as FailureBuckets };
}
describe('multi-user auth', () => {
it('rejects unauthenticated requests', async () => {
const res = await fetch(url('/api/status'));
@@ -157,7 +189,7 @@ describe('multi-user auth', () => {
expect(after.status).toBe(200);
});
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
it('verify-first: the per-username bucket never denies a correct password and self-heals failures (#17)', async () => {
rateServer = new WebServer(0, false, true);
await rateServer.start();
const rurl = (p: string) => `http://localhost:${rateServer.boundPort}${p}`;
@@ -167,9 +199,10 @@ describe('multi-user auth', () => {
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
expect(res.status).toBe(401);
}
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
// throttle) — the accumulated failures can never lock the account out — and success
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
// Finding #17: the per-username bucket is checked AFTER the verify, so it never denies
// a correct password, and success clears the failure buckets. Previously this returned
// 429 (the DoS being fixed). Nine failures also stay under the per-(username, IP) gate,
// which is checked before the verify; that is why the correct password is 200 here.
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
expect(good.status).toBe(200);
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
@@ -184,6 +217,212 @@ describe('multi-user auth', () => {
}
expect(limited).toBe(true);
});
it('per-IP pre-gate: wrong passwords spread across accounts are throttled at AUTH_IP_FAILURE_MAX, before scrypt', async () => {
// Fresh server so no earlier test's failures sit in the per-IP bucket.
sprayServer = new WebServer(0, false, true);
await sprayServer.start();
const surl = (p: string) => `http://localhost:${sprayServer.boundPort}${p}`;
// Spread the guesses so no single username reaches its own AUTH_FAILURE_MAX bucket:
// only the per-IP pre-gate can produce a 429 here.
const perUser = AUTH_FAILURE_MAX - 1;
for (let i = 0; i < AUTH_IP_FAILURE_MAX; i++) {
const user = `spray${Math.floor(i / perUser)}`;
const res = await fetch(surl('/api/status'), { headers: { Authorization: basic(user, `bad-${i}`) } });
expect(res.status, `attempt ${i + 1}`).toBe(401);
}
// One more failure from the same IP is refused by the pre-gate.
const over = await fetch(surl('/api/status'), { headers: { Authorization: basic('spray-extra', 'bad') } });
expect(over.status).toBe(429);
expect(over.headers.get('retry-after')).toMatch(/^\d+$/);
// The pre-gate runs before the password check, so even a correct password is refused.
const good = await fetch(surl('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
expect(good.status).toBe(429);
});
it('per-(username, IP) gate: failures lock that account on that IP only, before scrypt', async () => {
const { login } = await startIpServer();
const ipA = '203.0.113.10';
const ipB = '203.0.113.20';
// AUTH_FAILURE_MAX wrong passwords for bob from one IP. The last one is already a
// 429 from the per-username bucket, which counts before it checks.
for (let i = 0; i < AUTH_FAILURE_MAX; i++) {
const res = await login(ipA, 'bob', `bad-${i}`);
expect(res.statusCode, `attempt ${i + 1}`).toBe(i < AUTH_FAILURE_MAX - 1 ? 401 : 429);
}
// The (bob, ipA) bucket is checked before the password, so even the correct one is
// refused from that IP, far below the per-IP pre-gate.
expect(AUTH_FAILURE_MAX).toBeLessThan(AUTH_IP_FAILURE_MAX);
const lockedOut = await login(ipA, 'bob', 'bobpass123');
expect(lockedOut.statusCode).toBe(429);
expect(String(lockedOut.headers['retry-after'])).toMatch(/^\d+$/);
// Another IP cannot lock the account out: bob logs in from ipB.
expect((await login(ipB, 'bob', 'bobpass123')).statusCode).toBe(200);
// Another account behind the same IP is not locked out either.
expect((await login(ipA, 'alice', 'alicepass1')).statusCode).toBe(200);
// Neither success cleared bob's bucket on ipA: that one is per pair.
expect((await login(ipA, 'bob', 'bobpass123')).statusCode).toBe(429);
});
it('per-IP pre-gate: a concurrent burst from one IP reaches the verify at most AUTH_IP_FAILURE_MAX times', async () => {
const { login, buckets } = await startIpServer();
const ip = '203.0.113.40';
// More than AUTH_IP_FAILURE_MAX wrong passwords in flight at once, spread so neither
// the pair nor the per-username bucket can refuse any of them. All the gate reads
// happen before the first verify ends, so only a slot reserved before the scrypt
// await stops the burst passing the pre-gate on one stale count.
const extra = 10;
const perUser = AUTH_FAILURE_MAX - 1;
const burst = [];
for (let i = 0; i < AUTH_IP_FAILURE_MAX + extra; i++) {
burst.push(login(ip, `burst${Math.floor(i / perUser)}`, `bad-${i}`));
}
const codes = (await Promise.all(burst)).map((r) => r.statusCode);
// No account fills a bucket of its own here, so every verified guess is a 401 and
// every 429 is the pre-gate.
expect(codes.filter((c) => c === 401).length).toBe(AUTH_IP_FAILURE_MAX);
expect(codes.filter((c) => c === 429).length).toBe(extra);
// Each admitted guess counted once: reserved before the verify, not again after it.
expect(buckets.authFailures.get(ip)).toBe(AUTH_IP_FAILURE_MAX);
// The bucket is full, so the next request from that IP is refused before scrypt,
// a correct password included.
expect((await login(ip, 'burst-next', 'bad')).statusCode).toBe(429);
expect((await login(ip, 'alice', 'alicepass1')).statusCode).toBe(429);
});
it('per-(username, IP) gate: a concurrent burst cannot carry the right password past the cap', async () => {
const { login, buckets } = await startIpServer();
const ip = '203.0.113.50';
// AUTH_FAILURE_MAX + 5 logins for bob from one IP, all in flight at once, the LAST
// carrying the correct password. Counting only after the verify let every one of
// them past the gate on the same stale read, and the correct one logged in.
const burst = [];
for (let i = 0; i < AUTH_FAILURE_MAX + 4; i++) burst.push(login(ip, 'bob', `bad-${i}`));
burst.push(login(ip, 'bob', 'bobpass123'));
const codes = (await Promise.all(burst)).map((r) => r.statusCode);
expect(codes.at(-1)).toBe(429);
expect(codes.filter((c) => c !== 401 && c !== 429)).toEqual([]);
// Every failed verify adds one to bob's per-username bucket, so it counts the guesses
// that reached scrypt: exactly the AUTH_FAILURE_MAX the pair gate admits. (The last
// of those is a 429 from that bucket, which is why the 401s are fewer.)
expect(buckets.userFailures.get('bob')).toBe(AUTH_FAILURE_MAX);
expect(codes.filter((c) => c === 401).length).toBeLessThanOrEqual(AUTH_FAILURE_MAX);
// Reserved once each, not counted again on failure.
expect(buckets.userIpFailures.get(`${ip}\nbob`)).toBe(AUTH_FAILURE_MAX);
expect(buckets.authFailures.get(ip)).toBe(AUTH_FAILURE_MAX);
// The lockout is that pair's only: bob still logs in from another IP.
expect((await login('203.0.113.51', 'bob', 'bobpass123')).statusCode).toBe(200);
});
it('identical in-flight logins share one verify: a burst of correct ones all log in', async () => {
const { login, buckets } = await startIpServer();
const ip = '203.0.113.60';
verifyCalls.mockClear();
// A browser whose cookie died with a server restart replays its cached Basic header
// on many parallel requests. Had each reserved its own slot, every one past
// AUTH_FAILURE_MAX would be refused although all carry the right password.
const burst = [];
for (let i = 0; i < AUTH_FAILURE_MAX + 5; i++) burst.push(login(ip, 'bob', 'bobpass123'));
const responses = await Promise.all(burst);
expect(responses.map((r) => r.statusCode)).toEqual(burst.map(() => 200));
expect(verifyCalls).toHaveBeenCalledTimes(1);
// Each request still gets a cookie session of its own.
const tokens = responses.map((r) => /codeman_session=([^;]+)/.exec(String(r.headers['set-cookie']))?.[1]);
expect(new Set(tokens).size).toBe(burst.length);
expect(tokens).not.toContain(undefined);
// The one success cleared the buckets, the reservation included.
expect(buckets.userIpFailures.get(`${ip}\nbob`)).toBeUndefined();
expect(buckets.authFailures.get(ip)).toBeUndefined();
// The entry left the map when its verify settled: the next login verifies afresh.
expect((await login(ip, 'bob', 'bobpass123')).statusCode).toBe(200);
expect(verifyCalls).toHaveBeenCalledTimes(2);
});
it('identical in-flight logins share one verify: a burst of one wrong password counts once', async () => {
const { login, buckets } = await startIpServer();
const ip = '203.0.113.70';
verifyCalls.mockClear();
const burst = [];
for (let i = 0; i < AUTH_FAILURE_MAX + 5; i++) burst.push(login(ip, 'bob', 'same-wrong'));
const codes = (await Promise.all(burst)).map((r) => r.statusCode);
// One guess, verified once, counted once in each bucket, and the same 401 for all.
expect(codes).toEqual(burst.map(() => 401));
expect(verifyCalls).toHaveBeenCalledTimes(1);
expect(buckets.userFailures.get('bob')).toBe(1);
expect(buckets.userIpFailures.get(`${ip}\nbob`)).toBe(1);
expect(buckets.authFailures.get(ip)).toBe(1);
});
it('identical in-flight logins skip the gates: the slot their own verify took does not refuse them', async () => {
const { login } = await startIpServer();
const ip = '203.0.113.80';
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
expect((await login(ip, 'bob', `bad-${i}`)).statusCode, `attempt ${i + 1}`).toBe(401);
}
verifyCalls.mockClear();
// The first of these takes the pair's last slot; the rest join its verify, so a gate
// check of their own would refuse them with a full bucket.
const burst = [];
for (let i = 0; i < 5; i++) burst.push(login(ip, 'bob', 'bobpass123'));
const codes = (await Promise.all(burst)).map((r) => r.statusCode);
expect(codes).toEqual(burst.map(() => 200));
expect(verifyCalls).toHaveBeenCalledTimes(1);
});
it('a verify that throws leaves the in-flight map, and its joiners share the failure', async () => {
const { login } = await startIpServer();
const ip = '203.0.113.90';
verifyCalls.mockClear();
verifyCalls.mockRejectedValueOnce(new Error('user store unreadable'));
const burst = [];
for (let i = 0; i < 3; i++) burst.push(login(ip, 'bob', 'bobpass123'));
const codes = (await Promise.all(burst)).map((r) => r.statusCode);
expect(verifyCalls).toHaveBeenCalledTimes(1);
expect(new Set(codes).size).toBe(1);
expect(codes[0]).toBeGreaterThanOrEqual(500);
// Removed in a finally, so the next identical login runs a verify of its own.
expect((await login(ip, 'bob', 'bobpass123')).statusCode).toBe(200);
expect(verifyCalls).toHaveBeenCalledTimes(2);
});
it('per-(username, IP) gate: a correct password clears the pair bucket', async () => {
const { login } = await startIpServer();
const ip = '203.0.113.30';
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
expect((await login(ip, 'bob', `bad-${i}`)).statusCode, `attempt ${i + 1}`).toBe(401);
}
expect((await login(ip, 'bob', 'bobpass123')).statusCode).toBe(200);
// Had the success left the pair at AUTH_FAILURE_MAX - 1, the second of these would
// already be refused before the password check.
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
expect((await login(ip, 'bob', `again-${i}`)).statusCode, `retry ${i + 1}`).toBe(401);
}
expect((await login(ip, 'bob', 'bobpass123')).statusCode).toBe(200);
});
});
describe('QR token identity (tunnel-manager)', () => {
+31 -1
View File
@@ -10,10 +10,16 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js';
import {
canAccessOwned,
findSessionOrFail,
findSessionUnscopedOrFail,
sessionCapacityState,
} from '../src/web/route-helpers.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
@@ -154,6 +160,30 @@ describe('session-scoping helpers (multi-user)', () => {
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined();
});
it('findSessionUnscopedOrFail is the explicit unscoped lookup: returns a foreign session, 404s an unknown id', () => {
const map = new Map<string, unknown>([['s1', fakeSession('alice')]]);
expect(findSessionUnscopedOrFail(ctxWith(map), 's1')).toBeDefined();
expect(() => findSessionUnscopedOrFail(ctxWith(map), 'missing')).toThrow();
});
it('findSessionOrFail and findPersistedSessionOrFail declare req as REQUIRED, so an unscoped call must use the explicit helper', async () => {
// tsc does not typecheck test/, so pin the signatures from the source AST: an
// optional `req` would make forgetting it compile into an UNSCOPED lookup.
const file = path.join(import.meta.dirname, '../src/web/route-helpers.ts');
const src = ts.createSourceFile(file, await fs.readFile(file, 'utf8'), ts.ScriptTarget.Latest, true);
for (const name of ['findSessionOrFail', 'findPersistedSessionOrFail']) {
const decl = src.statements.find(
(s): s is ts.FunctionDeclaration => ts.isFunctionDeclaration(s) && s.name?.text === name
);
const req = decl?.parameters.find((p) => p.name.getText(src) === 'req');
expect(req, name).toBeDefined();
expect(req?.questionToken, name).toBeUndefined();
expect(req?.initializer, name).toBeUndefined();
// Exactly `FastifyRequest`: a later `FastifyRequest | undefined` would be optional in all but syntax.
expect(req?.type?.getText(src), name).toBe('FastifyRequest');
}
});
it('per-user session cap counts only the owner sessions', () => {
const map = new Map<string, unknown>([
['a', fakeSession('alice')],
+17
View File
@@ -77,4 +77,21 @@ describe('every terminal response feeds it (static guard)', () => {
expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);');
});
it('the refresh records the pane only once it still owns the terminal, and no caller records an error envelope', () => {
// A refresh that lost its owner is older than what the switch back read; an
// error envelope has no count, and forgetting on it would hand a scroll back
// to the paging fallback (see _maybePageCliTranscript).
const refresh = methodSource(APP, '_onSessionNeedsRefresh');
const ownerCheck = refresh.indexOf('this._terminalRefreshOwner !== refreshOwner) return false;');
expect(ownerCheck).toBeGreaterThan(-1);
expect(refresh.indexOf('this._notePaneHistory?.(sessionId, data);')).toBeGreaterThan(ownerCheck);
expect(refresh).toContain('capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);');
expect(methodSource(APP, 'selectSession')).toContain(
'capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);'
);
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain(
'capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);'
);
});
});
+7 -2
View File
@@ -26,7 +26,12 @@ import {
decideReconnect,
} from '../src/remote-reconnect.js';
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
import { buildRemoteSessionAliveCommand, classifyRemoteAliveExit } from '../src/remote-hosts.js';
import {
buildRemoteSessionAliveCommand,
classifyRemoteAliveExit,
shellescape,
withRemoteTmuxPath,
} from '../src/remote-hosts.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
@@ -227,7 +232,7 @@ describe('remote has-session probe (pure)', () => {
// Literal pin: the session name is shellescaped inside the remote command,
// which is itself one shellescaped ssh argument.
expect(cmd).toBe(
"ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box 'tmux -L codeman-remote has-session -t '\\''codeman-ssh-abc'\\'' 2>/dev/null'"
`ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box ${shellescape(withRemoteTmuxPath("tmux -L codeman-remote has-session -t 'codeman-ssh-abc' 2>/dev/null"))}`
);
});
+2 -2
View File
@@ -11,7 +11,7 @@
import { execFileSync } from 'node:child_process';
import { describe, it, expect } from 'vitest';
import { parseRemoteSessionList } from '../src/remote-hosts.js';
import { parseRemoteSessionList, withRemoteTmuxPath } from '../src/remote-hosts.js';
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
@@ -103,7 +103,7 @@ describe('COD-105 buildRemoteAttachCommand', () => {
.map((l) => l.slice(2));
expect(sshArgs).toContain('ubuntu@10.0.0.42');
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
expect(tmuxArg).toBe(withRemoteTmuxPath("tmux -L codeman attach -t 'codeman-disco1'"));
});
});
+18 -7
View File
@@ -13,7 +13,7 @@
* `-o ProxyCommand=…` token; the local shell must not expand/mangle them).
* - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at
* build time (ssh does NOT expand `~` in `-i`), then shellescaped.
* - empty options ⇒ byte-identical ssh to today (full back-compat).
* - empty options retain the SSH option order; tmux gets the shared PATH wrapper.
*
* Pure command-string construction; no real tmux, no ssh. Port: N/A.
*/
@@ -25,6 +25,8 @@ import {
buildRemoteTmuxCheckCommand,
buildRemoteCliVersionProbeCommand,
remoteSshTarget,
shellescape,
withRemoteTmuxPath,
} from '../src/remote-hosts.js';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
@@ -146,10 +148,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
expect(idxProxy).toBeLessThan(idxTarget);
});
it('a remote with no advanced options is byte-identical to the expected form', () => {
it('preserves the no-options launch command inside the shared remote PATH wrapper', () => {
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID });
// Reconstruct the command using the SAME nested POSIX single-quote escaping the
// production code uses, to prove byte-identity. Session runs on the DEDICATED
// production code uses, preserving the payload inside the PATH wrapper. The DEDICATED
// `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's
// discovery ignores; set-options are scoped per-session (never `-g`).
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
@@ -164,13 +166,22 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
'set -s escape-time 0',
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
`set -t ${remoteName} window-size latest`,
// #671 — the nested server's prefix table is stock unless this chain
// reduces it. Ctrl+Q then ) would switch-client onto a sibling session.
// Bind first: a user config that emptied the prefix table makes
// `unbind-key -a` fail and tmux drops the rest of the `\;` chain,
// including the bind and remain-on-exit. That option stays last because
// a remote tmux that rejects `failed` aborts the rest of the chain.
'bind-key -T prefix C-q send-prefix',
'unbind-key -a -T prefix',
'bind-key -T prefix C-q send-prefix',
// #210 — keep a CRASHED pane for diagnosis. `failed` (not `on`, which would
// also strand a pane after a clean `exit`), and LAST because tmux aborts the
// remaining commands of a `\;` chain on error and `failed` needs tmux >= 3.2.
`set -t ${remoteName} remain-on-exit failed`,
].join(' \\; ');
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(withRemoteTmuxPath(tmuxInvocation))}`;
expect(command).toBe(expected);
});
@@ -193,12 +204,12 @@ describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
expect(cmd).toContain('aakht@192.168.55.170');
expect(cmd).toContain("'command -v tmux'");
expect(cmd).toContain(shellescape(withRemoteTmuxPath('command -v tmux')));
});
it('back-compat: no advanced options ⇒ unchanged probe string', () => {
it('no advanced options preserve the ssh prefix and wrap the tmux probe', () => {
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe(
"ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'"
`ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 ${shellescape(withRemoteTmuxPath('command -v tmux'))}`
);
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
});
+167
View File
@@ -0,0 +1,167 @@
/** Real shell/argv regression for every remote tmux builder. No network or real tmux runs. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildRemoteTmuxCheckCommand,
buildRemoteSessionAliveCommand,
buildRemoteListSessionsCommand,
withRemoteTmuxPath,
} from '../src/remote-hosts.js';
import {
buildRemoteLaunchCommand,
buildRemoteAttachCommand,
buildRemoteKillCommand,
remoteTmuxSessionName,
} from '../src/tmux-manager.js';
import { buildRemoteScrollbackCommand, parseRemoteScrollbackOutput } from '../src/remote-pane-history.js';
import type { SessionRemote } from '../src/types/session.js';
const id = '663e3eae-8da6-4c7f-9231-611eb2840641';
const marker = 'path-history-marker';
const remote: SessionRemote = {
hostId: 'offline',
label: 'Offline fixture',
host: 'never-connect.invalid',
username: 'tester',
port: 2222,
identityFile: '/tmp/key with spaces',
jumpHost: 'jump.invalid',
socksProxy: '127.0.0.1:1080',
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
remotePath: '/tmp/project with spaces',
};
const discovered = "team's session $literal";
const cases = [
{ name: 'link preflight', build: () => buildRemoteTmuxCheckCommand(remote), command: null, socket: null },
{
name: 'launch',
build: () => buildRemoteLaunchCommand({ remote, sessionId: id, mode: 'shell' }),
command: 'new-session',
socket: 'codeman-remote',
},
{ name: 'attach', build: () => buildRemoteAttachCommand(remote, discovered), command: 'attach', socket: 'codeman' },
{
name: 'alive probe',
build: () => buildRemoteSessionAliveCommand(remote, remoteTmuxSessionName(id)),
command: 'has-session',
socket: 'codeman-remote',
},
{
name: 'discovery',
build: () => buildRemoteListSessionsCommand(remote),
command: 'list-sessions',
socket: 'codeman',
},
{
name: 'history and screen capture',
build: () => buildRemoteScrollbackCommand(remote, id, 100, 0, marker),
command: 'capture-pane',
socket: 'codeman-remote',
},
{
name: 'cleanup',
build: () => buildRemoteKillCommand({ remote, sessionId: id }),
command: 'kill-session',
socket: 'codeman-remote',
},
];
describe('remote tmux PATH under a sparse non-interactive shell', () => {
let root: string;
let home: string;
let sshBin: string;
let tmuxPath: string;
let tmuxLog: string;
let sshLog: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'remote-tmux-path-'));
home = join(root, 'remote home with spaces');
sshBin = join(root, 'ssh tools');
tmuxPath = join(home, '.local', 'bin', 'tmux');
tmuxLog = join(root, 'tmux-args');
sshLog = join(root, 'ssh-args');
mkdirSync(join(home, '.local', 'bin'), { recursive: true });
mkdirSync(sshBin);
writeFileSync(
join(sshBin, 'ssh'),
`#!/bin/sh
for arg do printf '%s\\n' "$arg" >> "$TEST_SSH_LOG"; command="$arg"; done
exec "$TEST_REMOTE_SHELL" -c "$command"
`,
{ mode: 0o755 }
);
writeFileSync(
tmuxPath,
`#!/bin/sh
for arg do printf '%s\\n' "$arg" >> "$TEST_TMUX_LOG"; done
case "$*" in
*capture-pane*) printf 'older row\\n${marker}\\n1\\ncurrent screen\\n' ;;
*list-sessions*) printf 'discovered\\t0\\t123\\t1\\n' ;;
esac
`,
{ mode: 0o755 }
);
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
// Literal pin for the quotes. dash 0.5.11+, bash and busybox do not field-split
// `export` arguments, so the real-shell cases below also pass with an unquoted
// PATH, and the other builder tests compute their expectation from the wrapper.
it('double-quotes the whole exported PATH value', () => {
expect(withRemoteTmuxPath('tmux -V')).toBe(
'export PATH="$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; tmux -V'
);
});
for (const shell of ['/bin/sh', ...(existsSync('/bin/dash') ? ['/bin/dash'] : [])]) {
for (const item of cases) {
it(`${item.name} finds the remote-user tmux with ${shell} and preserves targeting/options`, () => {
writeFileSync(tmuxLog, '');
writeFileSync(sshLog, '');
const stdout = execFileSync('/bin/sh', ['-c', item.build()], {
encoding: 'utf8',
env: {
...process.env,
HOME: home,
// Only the SSH stub is initially on PATH; an unwrapped command cannot
// accidentally run this machine's real tmux during a negative test.
PATH: sshBin,
TEST_REMOTE_SHELL: shell,
TEST_TMUX_LOG: tmuxLog,
TEST_SSH_LOG: sshLog,
},
});
const sshArgs = readFileSync(sshLog, 'utf8').trimEnd().split('\n');
expect(sshArgs).toContain('tester@never-connect.invalid');
expect(sshArgs).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
expect(sshArgs).toContain('StrictHostKeyChecking=accept-new');
expect(sshArgs.slice(sshArgs.indexOf('-p'), sshArgs.indexOf('-p') + 2)).toEqual(['-p', '2222']);
expect(sshArgs.slice(sshArgs.indexOf('-i'), sshArgs.indexOf('-i') + 2)).toEqual(['-i', '/tmp/key with spaces']);
expect(sshArgs.slice(sshArgs.indexOf('-J'), sshArgs.indexOf('-J') + 2)).toEqual(['-J', 'jump.invalid']);
if (item.command === null) {
expect(stdout.trim()).toBe(tmuxPath);
return;
}
const args = readFileSync(tmuxLog, 'utf8').trimEnd().split('\n');
expect(args.slice(0, 2)).toEqual(['-L', item.socket]);
expect(args).toContain(item.command);
if (item.name === 'attach') expect(args).toContain(discovered);
if (item.name === 'launch') {
expect(args).toContain(remote.remotePath);
expect(sshArgs).toContain('-t');
}
if (['alive probe', 'cleanup'].includes(item.name)) expect(args).toContain(remoteTmuxSessionName(id));
if (item.name === 'history and screen capture') {
expect(args).toContain(`=${remoteTmuxSessionName(id)}:`);
expect(parseRemoteScrollbackOutput(stdout, marker, 0)).toEqual({
scrollback: 'older row\n',
screen: 'current screen\n',
});
}
});
}
}
});
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more