Fifteen review findings on the dsh mode, the serious ones first:
- Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys.
_configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into
every dsh pane and applyEnvOverrides() lands after it, so a non-granted
owner who could redirect the base URL would have the operator's key sent
as a bearer credential to a host of their choosing.
- Wait registry: until=stop/blocked is refused on docker and remote-SSH
dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions).
The HERDR triple is set via LOCAL tmux setenv, which crosses neither
docker exec nor ssh, so such a session can never post a hook event and
the wait burned its whole timeout on every turn.
- Approvals: a dsh item is an ALERT, not an answerable card. The answer
route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the
option parser cannot read a third-party TUI's frames, so an answer was a
blind keystroke into a foreign composer), and the push notification
carries no Approve/Deny actions for dsh sessions.
- Status shim (v3): --seq is forwarded and the server drops stale retried
reports inside a 60s window (the TUI retries with backoff, so a retried
'working' could land after 'blocked' and resolve an approval whose
dialog was still on screen); 4xx responses exit 0 instead of retrying,
so one misconfigured session cannot feed the auth rate-limit bucket
until the hook endpoint 429s for the whole instance.
- Web-UI server: concurrent starts are serialized through a lock (two
racing POSTs used to pick the same port and orphan the winner), and the
readiness poll / timeout paths only clear or stop the singleton while it
is still theirs. First click actually opens the tab now
(refreshWebviews, not the nonexistent loadWebviews). DELETE
/api/deepseek/web requires the privileged grant in multi-user mode.
- Cron: deepseek jobs run the same two-part launch gate as the HTTP
create paths (impl moved into the resolver so all three share it) and no
longer stamp a Claude default model on the session.
- Parity sweeps: quick-start's docker branch rejects deepSeekConfig like
the remote branch; the Ralph auto-enable list gained deepseek;
HookEventType gained agent_working; the phone overview run menu filters
managed webview records like the desktop menu.
- install.sh: the dsh identity probe closes stdin (under curl|bash a
child that reads stdin eats the rest of the script), bounds the exec
with timeout where available, and is memoized to one scan per install.
- Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand
identity (it rendered as an unstyled UA-grey button); stale markup
comment about the web shortcut rewritten; clamp docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four review findings on the worker-transcript feature:
- Docker and remote-SSH dsh sessions now keep the pane segmenter: their
transcripts live in the container's / remote host's own ~/.dsh, which
the local reader can never see, so the transcript path returned
'nothing said yet' forever and an agent polling such a worker starved
on an answer that existed. Gated on !session.docker && !session.remote
(statically pinned) and documented in the integration guide.
- last-response reads are memoized on (path, mtime, size, blocks): the
skill's last_text polls once per second, and each poll decompressed and
reparsed the whole file on the event loop even when nothing had been
appended. An unchanged poll now costs one stat.
- The pairing ladder's comment claimed /new is served by step 2; in truth
the boot-window transcript wins for as long as it exists (deliberately:
preferring newest-eligible would hand a worker its busier sibling's
reply). The comment now states the real tradeoff instead of the
aspirational one. Same for decodeZstdFrames' 'skipped' wording — a
corrupt frame truncates the decode there, which is the safe behavior.
- stripReasoningPrefix no longer runs on user prompt text, so a prompt
containing a literal </think> renders whole in blocks view.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three review findings on the detailed-rows feature, all in its edge cases:
- The App Settings width select consulted the handheld defaults blob
(tabRailWidth: 256) BEFORE the rich-aware default, which the renderer
never reads — so a tablet's unsized rich rail rendered 320 while the
dialog said 256, and a routine Save persisted the 256 (below the 288px
tight threshold, permanently). The chain now mirrors
applyTabRailWidth()'s actual resolution.
- _setTabRailWidth() re-rendered on a compact flip but never re-ran
applyTabWrapSettings(), the one owner of the folder line, whose railRich
input reads the compact class this function just toggled. A rich rail
dragged below 240px kept emitting folder rows — persistently, for a
stored width < 240, since the boot wrap pass runs before the class is
first applied. The wrap pass now re-runs on the flip, with exactly one
render either way.
- Both reset affordances (handle dblclick, Enter on the handle) reset to
the hardcoded 256 even on a rich rail, landing it below the tight
threshold; both now resolve the rich-aware default (320), via a new
optional defaultWidth input on resolveTabRailKeyboardWidth().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent skill could spawn a worker in any mode, but it could only
DRIVE a claude one: every other CLI has neither a real end-of-turn
signal nor an answer to read, so the recipes route them through output
markers.
dsh has both halves now -- its harness reports idle/working/blocked to
Codeman, and the previous commit reads its transcript -- so it joins
claude as a mode the four verbs work on unchanged. `spawn_workers alpha
beta:deepseek` is a mixed fleet in one call, and `sendwait` / `last_text`
/ `delete_session` need no per-mode variant.
Preamble 1.20.0 (SKILL.md's §0 heredoc regenerated from it):
- `spawn_worker` grows a deepseek branch that gates on the harness
composer. ⚠️ Readiness there is NOT the stop signal: the harness
reports idle at BOOT ~300 ms before its composer paints (measured
2.26 s vs 2.56 s after spawn), so a send-and-wait fired straight after
quick-start resolves on the boot edge, reports a turn that never ran,
and strands the prompt in a pane not yet taking input. Waiting for the
composer also spends that edge, since signals are edge-triggered.
- `spawn_workers` takes `name[:mode]`, so a mixed fleet stays one
concurrent call. Case names still have to be unique -- the mode never
disambiguates two workers that would share a directory.
- `sendwait` asks for `wait:"stop,exit"` instead of the `wait:true`
default set. That set also carries `idle`, which for an external CLI is
inferred from output stabilization: on a dsh worker whose TUI repaints
rarely, the re-wait resolved in 0 ms with `signal:"idle"` on a turn
with three minutes left to run. It also makes a wrong mode loud -- the
modes that cannot deliver `stop` answer 400 before writing anything,
instead of resolving on a flap.
- The self-heal resend carries `delivered:true` forward. The resend is a
tagged duplicate, so the server truthfully reports `delivered:false`
about a write it skipped, and §1's cleanup then read a completed turn
as an undelivered one and kept a finished worker forever.
- dsh workers spawn with the permission posture the Run button sends,
because the harness default still asks and a worker parked on an
approval row cannot finish a fan-out. The multi-user clamp still
applies.
Docs: a worked dsh flow in recipes.md, readiness and the signal rules in
verbs.md, and the corrections this makes necessary -- `stop`/`blocked`
are no longer claude-only, and `last-response` is no longer permanently
empty for deepseek. The integration guide gains a section on reading a
session back and driving one as a worker; its web-UI section was also
stale (that server moved out of a shell session).
The static guard that keeps those lists from naming some external CLIs but
not others is extended rather than exempted: it now knows the three real
classes inside that family (no transcript, no hook signals, and the
positive twin -- the modes whose answers can be read), with the hook class
derived from `hooksAvailableForMode()` so the predicate and the prose
cannot drift apart. Any other partial list still fails, and a new backend
belongs to none of the classes until someone says so.
`GET /api/sessions/:id/last-response` is how an agent (and the Response
Viewer) reads what a worker said. DeepSeek was falling through to the
pane segmenter with the other external CLIs, which for this mode is not
merely coarse but wrong: dsh-TUI paints a full-screen splash, so a
`last-response` call on a fresh dsh session answered with its ASCII-art
logo -- and anything polling for a worker's first reply reads that as a
reply.
dsh does not belong in that group. It writes a structured JSONL
transcript per session, so read it. Four things in that file shaped the
reader, all measured against real transcripts on disk:
1. dsh appends ONE ZSTD FRAME PER WRITE, and Node's zlib zstd decoder
(one-shot and streaming alike) stops at the first frame end: a real
56-line transcript decoded as 1 line / 158 bytes -- the session header
alone, i.e. a silent truncation that reads as "nothing said yet"
forever. `zstdFrameRanges()` walks frame and block headers to find
exact boundaries; splitting on the 4-byte magic would corrupt
everything after a magic sequence occurring inside compressed data.
zstd is resolved at RUNTIME because it landed in Node 22.15 while the
project floor is 22.0, so an older Node keeps the pane behaviour.
2. Every turn also records a plugin-sourced `user/message` (the runtime
context snapshot), which must not render as the user's own words.
3. A turn that ends in an error carries the provider's message; it is
surfaced as `Turn error: …` (and a non-error early stop as
`Turn ended: …`) rather than as an empty string, which an agent reads
as "still thinking" through fifteen polls.
4. Reply text is assembled per (turn, step): a finalized message wins and
the streamed deltas fill in only for a step that never finalized, so a
partial answer is readable mid-turn and never doubled. "Finalized" is
tracked as a set of steps rather than as non-empty text, because a
step whose whole reply was reasoning strips to '' at the `</think>`
boundary and would otherwise resurrect the raw deltas in its place.
Session-to-transcript pairing is by the transcript's own header `cwd`
plus a boot window against the session's createdAt, never by
reproducing dsh's directory mangling (already two forms on disk) and
never by newest-mtime alone -- mtime alone handed a freshly spawned
worker its predecessor's answer in the same case directory.
An empty result still wins over the pane; only a Node that cannot decode
zstd falls back to it.
Clicking "DeepSeek web UI..." opened two tabs: the web tab asked for, and a
shell tab running the server next to it. The shell was deliberate - the server
lived in an ordinary session so it was visible, scrollable, killable and died
with its tab, and nothing new had to supervise a long-lived HTTP server. That
reasoning was sound and the result was still wrong in use: opening a dashboard
should open one tab, and after the first launch the terminal is pure noise.
The server moves to a background child process owned by a new
`src/deepseek-web-server.ts`, behind `POST /api/deepseek/web`. What the session
gave away for free is now explicit, which is most of the module:
- Exactly one server. A second click reuses the running one instead of racing
it for a port; the session flow could not do this at all, because two clicks
were simply two sessions.
- Restarted when the requested authority changes. `--trusted-host` fences dsh's
own /api against the browser authority, and a Codeman reachable at both
loopback and a tailnet name has two. Reusing a server fenced for the other
origin renders a page whose every call 403s, which reads as a broken
dashboard rather than a misconfigured one, so a mismatch restarts instead.
- Killed on shutdown. The child is detached so its whole plugin tree can be
signalled at once, which also means it would outlive Codeman and hold its
port against the next start - the exact EADDRINUSE this feature already got
wrong once.
- Boot output captured and returned. With no shell tab there is nowhere else
for a stack trace to land, so a failed spawn reports its own tail.
The endpoint is fenced at the same bar as the profile installer and for the
same reason: booting a dsh profile executes the plugin code in it, so this is a
privileged action even though it reads as "open a page". `authority` comes from
the client (`location.host`) because only the browser knows which origin is in
play, and it is regex-confined at the schema boundary - defence in depth behind
the argv-array spawn, admitting host:port in the shapes a browser authority can
take and nothing readable as a second argument.
`GET /api/deepseek/web-port` is gone; port selection moved into the supervisor,
which is the thing that knows whether a server is already running. The two
client-side probe helpers went with it, since the server now owns the wait.
Verified over the tailnet authority end to end: no session is created (session
count unchanged, one tab), the server runs on 3081 beside the user's own dsh
web on 3080, status reports the tailnet authority, and the proxied dashboard
renders with zero 4xx. Full gate green (6148 passed, +6).
The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real
install, and the three are independent.
1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it
precisely the port a DeepSeek user is most likely to be serving on already,
so the launch died with EADDRINUSE against the user's own server. The port
now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a
free loopback port by BINDING it (a connect probe cannot tell "free" from
"listening but not answering yet").
2. It opened the tab unconditionally. The crashed server left a saved dashboard
pointing at nothing, with the failure only visible in a shell tab nobody had
a reason to look at. The launch now polls the existing webview probe until
the URL answers, and on timeout reports the error naming the shell tab
instead of persisting a dead dashboard.
3. The saved tab was untrusted, so the frame was sandboxed without
`allow-same-origin` and the dashboard was broken twice over: the dsh
client-runtime reads `localStorage` while loading its plugins and died there
("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an
opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd
every `/api` call no matter which authority `--trusted-host` named. Passing
`location.host` only means anything once the frame actually carries that
origin, so `--trusted-host` had never once done its job. The managed tab is
now created `trusted: true`.
That trade is real and deliberate: a trusted proxied frame is same-origin
with Codeman and can reach Codeman's API. It is defensible only because this
dashboard is an agent harness Codeman just started itself, on loopback, which
can already run code as the user. It is not a precedent for trusting
third-party dashboards, which is why it is set at this one call site rather
than defaulted.
Separately, the shortcut listed its own dashboard twice: once as the menu entry
that starts it and once as the row that entry had written on the previous click.
Webviews now carry an optional `managed` marker, managed rows are filtered out
of the saved-dashboard list, and a relaunch repoints the existing row rather
than stacking one dead dashboard per restart (which the per-launch port would
otherwise guarantee). `managed` is declared in the schema because a plain
`z.object` strips undeclared keys, so an undeclared marker would never survive
the round trip.
`DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a
hand-started `dsh web` and the shortcut "land on the same place and share one
saved tab", which is the bug stated as a feature.
Verified on a real install with the user's own `dsh web` holding 3080: the
shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the
run menu, and the proxied dashboard renders its workspaces and completes its own
API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate
green (6142 passed), typecheck/lint/format/public-assets clean.
Two review nits on the vertical rail's detailed rows.
1. The tab-rail-tight rule (below 288px) hides `.tab-meta-created`, and its
comment claimed the value "survives in the row's title attribute either way".
It did not: the only title carrying it lived ON that element, and a
`display: none` element has no hover target, so the created stamp was not
shrunk but gone with no way to ask for it. Rather than just correcting the
comment, `_sidebarRichMetaHTML()` now puts BOTH absolute stamps on the
`.tab-meta` line itself, so the pill and the gaps around the stamps remain as
hover targets. An item's own title still wins where the item is visible.
2. applyTabOrientation() decided whether applyTabWrapSettings() had already
re-rendered by comparing `_tallTabsEnabled` before and after. That reads an
UNDEFINED previous value as "it rendered", but applyTabWrapSettings()
deliberately renders nothing on its first call ever (it only establishes the
baseline: `prevTallTabs !== undefined && prevTallTabs !== showFolder`). So on
a first call that also flips the folder row, neither function rendered and the
rows stayed stale. Reachable when the pre-paint script throws and leaves the
layout attributes on their catch-branch fallbacks for applyTabOrientation() to
correct. The guard now mirrors applyTabWrapSettings()'s own condition.
Both new tests were run against the unfixed code first and fail there, which is
the only thing that makes them regression tests. (The third, "does not render
twice", passes either way by design: it pins that fix 2 did not introduce a
double rebuild.)
Verified in a real browser against a live server with two sessions, driving the
narrowing through _setTabRailWidth() the way the resize drag does: at the 320
default the row reads "CREATED 2m ago · IDLE <1m" with the created element
displayed; at 256 the tight class is on, the created element computes to
display:none, the visible text drops to "IDLE <1m", and the meta line's title
still reads "First created: ...". At 220 the compact threshold drops rich rows
entirely. Screenshots confirm no truncation artifacts in either state.
Full gate green (6104 passed), typecheck, lint, format, frontend-syntax and
public-assets all clean.
The three smaller review nits, plus the first real test coverage for the status
shim (it had none: it is emitted as a STRING, so tsc never sees it).
1. The shim was written with a plain writeFileSync. The TUI can be exec'ing that
exact path while an upgraded Codeman refreshes it, and a reader catching a
half-written file gets a syntax error, exits non-zero, and is retried four
times per state change for a file that will never parse. Now temp + rename
(atomic within the directory), with the temp chmod'ed before the rename since
writeFileSync's mode only applies on create, and removed if the write throws.
SHIM_VERSION bumped to 2, because SHIM_SOURCE changed and an existing v1 shim
would otherwise keep matching the embedded marker and never be refreshed.
2. The pane-id comment claimed the ambient env "cannot be spoofed by an argument
the agent itself could influence". The agent runs IN that pane and can invoke
the shim with CODEMAN_SESSION_ID unset and any argv it likes. It buys nothing
it did not already have (the hook-secret file is readable from the same pane,
so it can POST /api/hook-event directly), but the comment read like a security
boundary. Rewritten to say what the preference actually buys: correct
attribution when a TUI mangles or re-uses the pane argument. Accidents, not
adversaries.
3. classifyProfile() folded the directory name into the same haystack as the
bundles, but only the TUI arm could match a bare name, so a stock profile
whose package.json has no dsh.profile.bundles (hand-edited, older layout,
mid-install) classified as `unknown` -> launchable -> eligible as the DEFAULT
pick, which is exactly the pane-dies-on-arrival failure the two-part
availability gate exists to prevent. The stock names are now a LAST-resort
fallback consulted after the bundle patterns, so real bundle evidence still
wins over a name the user chose. The loose `tui` arm gained word boundaries:
it decides which profile boots by default, and matching the middle of
`intuition` is not a rule anyone could predict.
New test/deepseek-status-shim.test.ts runs the generated script the way the
harness does -- real node process, real argv, real env, real listener -- and
covers the exit-code contract that makes the retry behaviour safe: mapped states
post and exit 0, an unknown verb or unmapped state exits 0 WITHOUT posting (a
non-zero there would be four HTTP requests per state change forever), a rejecting
server or an unreachable one exits non-zero so the caller retries, the hook secret
is read at execution time, and `node --check` parses the file (a template-literal
typo in SHIM_SOURCE is invisible to tsc).
Trap worth recording, hit while writing it: the tests must spawn the shim
ASYNCHRONOUSLY. The listener lives in the test process, so spawnSync blocks the
event loop that has to accept the connection, the shim waits out its own 1500ms
socket timeout and exits 1, and it reads exactly like a broken shim (measured:
Socket._onTimeout in its --trace-exit output, server logging nothing).
Verified: full gate green (6142 passed, +10), typecheck/lint/format clean.
Three review findings on the DeepSeek Harness mode, plus one the third exposed.
1. The multi-user clamp was bypassable by a sibling field on the same request.
clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but
DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER
_configureDeepSeek(), so a non-granted owner sending
envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated
instance: a session created with permissionMode "read-only" and that override
ran with DSH_PERMISSION_MODE=danger-full-access in its pane.
Every other CLI's bypass is a command-line flag reachable only through the
per-CLI config, which is why the config clamp alone is the whole gate for
them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted
owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to
what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on
that list because it aims the launcher at a profile tree whose plugin code
runs at boot, before any approval row can apply. Verified end to end in real
multi-user mode: a non-granted user sending both now gets workspace-write and
no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched.
2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout`
signals only the direct child, and a plugin install fans out into
package-manager children that keep the inherited stdio pipes open, so `close`
never fires and the held-open request leaks with no route-level deadline.
Reproduced: with a 1.5s built-in timeout the promise was still unsettled after
6s and both fan-out children were alive. Now detached: true plus negative-pid
SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a
last-resort reap for a grandchild that escaped the group. Same probe after the
change: close fires, direct child and both grandchildren dead.
3. hooksAvailableForMode() promised more than a dsh session can deliver.
deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that
triple is the only reason a dsh session posts hook events, so `until=stop` was
accepted and then blocked for the caller's whole timeout: the exact
infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now
takes HookCapabilityOptions and every call site passes sessionHookOptions(),
with the deepseek arm reading `!== false` so a forgotten one degrades to the
old behaviour. The refusal names the setting rather than saying "no Claude
Code hooks", which would send the caller hunting a bug that is really a
setting they chose. Profile conformance stays unknowable at request time and
is documented as such. The stale "True for `claude` and nothing else" docblock
is corrected.
4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a
claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent
capture read Claude's own transcript, and adding deepseek silently widened
both to a mode that has none. They compare mode === 'claude' directly now, and
a static check pins them there.
Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the
wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 --
bridge off plus explicit until=stop is a 400 naming the setting, bridge off with
no `until` still 200s on idle/exit, bridge on accepts stop.
The vertical tab rail (tabOrientation 'vertical') listed names and nothing
else, while the rich sidebar and both home screens already answered the
question a docked column exists to answer: which of these sessions wants me
next, and how long has it been like that. The rail is a docked column too, so
it now draws the same row.
- New per-device setting tabRailDetail ('rich' | 'simple', default rich),
App Settings -> Appearance -> Tabs, in SettingsUpdateSchema + displayKeys and
stamped as data-tab-rail-detail by the pre-paint script, so a detailed rail
does not flash through simple rows on every load.
- ONE gate for both vertical surfaces: isRichTabRows() =
isSessionSidebarRich() || isTabRailRich(). The row model, the markup and the
20s in-place clock are the existing rich-sidebar ones, classified by
_mobileOverviewState/_mobileOverviewSince, so the rail, the sidebar, the
desktop home rail and the phone overview cannot disagree about what
"working" means or which stamp measures it.
- Detail rides on its OWN attribute, exactly as the sidebar's does, so every
existing [data-tab-orientation='vertical'] rule keeps matching both variants
untouched. A flip of detail ALONE still forces a full render (the stamps line
is emitted by the row template, not toggled by CSS) and re-runs
applyTabWrapSettings(), which owns the folder line and is now rail-aware.
- CSS: every rich paint rule gains a rail twin as a COMMA-GROUPED selector,
never :is() - an :is() list takes its most specific argument, which would
lift the sidebar arm from (0,3,1) to the rail's (0,5,1) and let these rules
outrank things they never used to.
- Width is why there are thresholds. At 256px the stamps line ellipsizes
mid-word, the same reason the rich sidebar is 300px, so a rail that has never
been sized defaults to 320 (RICH_DEFAULT_WIDTH, the existing Wide preset,
which also keeps the settings select on a named choice). A width the user has
chosen is never overridden: below 288px the created stamp is dropped rather
than truncated (tab-rail-tight, CSS only) and below 240px the rows go back to
simple (tab-rail-compact, which re-renders).
- The rich clock is armed and disarmed by applyTabOrientation() as well as
applySessionListLayout(); a leaked interval would rewrite stamps in a list
that no longer has any.
Also fixes a data-loss bug in the inline tab rename that predates the rail and
reproduces in every layout, header strip included: Escape set the input to ''
and blurred it, and the blur handler commits - so cancelling a rename PUT an
empty name, and the tab fell back to its folder label (measured against a live
server: ["rail-alpha","","rail-gamma"]). Escape now calls cancelRename(), which
invalidates the edit so the blur that follows the input's removal is a no-op.
Tests: rail-detail gate, the three ways it turns back off (simple, compact,
horizontal), sidebar-wins, render-on-detail-flip and the plumbing/CSS guards in
test/session-list-layout.test.ts; the rename cancel in test/inline-rename.test.ts
(browser suite), pinned by running it against the old code first. Verified live
against a real server on an isolated instance: detailed/simple/compact/header/
sidebar variants, click-select, the ... menu, inline rename, Alt+N, the in-place
stamp tick and a full settings-picker round-trip including reload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/
antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI
as a Codeman web tab.
DeepSeek is wired unlike its siblings in three ways, each of which is the
reason for a design decision rather than an accident:
1. The agent is a PROFILE, not the binary. `dsh` is a launcher over
$DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and
`base` -- the interactive terminal front door is always a third-party
plugin. So availability is two questions: `isDeepSeekAvailable()` (binary)
and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run
button gates on the latter, because reporting only the binary would spawn a
pane that dies on arrival. When the binary is present but no profile is,
the run menu offers to install one (POST /api/deepseek/install-profile).
2. The permission switch is an env var, not a flag. The harness has no
command-line permission option; its sandbox/approval rows read
DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access).
Exported via `tmux setenv`, never on the spawn line. Absent = the harness's
own workspace-write, which still asks, so the multi-user clamp is the
only-if-sent branch and clamps to workspace-write, never read-only.
3. It is the only non-claude mode that passes hooksAvailableForMode(), and it
earned that. The terminal front door reports idle/working/blocked to a
supervising process over a generic env-gated contract; a generated shim
(deepseek-status-shim.ts) makes Codeman that supervisor and forwards each
report to /api/hook-event as stop / agent_working / permission_prompt. So a
dsh session gets definitive respawn triggers, real wait-endpoint signals and
real Approvals Inbox items instead of output-stabilization guesswork.
`agent_working` is new (157th SSE constant) and joins
APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its
alert at once.
The resolver needs the strictest identity probe of the family: `dsh` is not
merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell),
so `dsh --help` must print the harness's own banner before a candidate is
handed a spawn line.
Model is deliberately not a session field -- it is a composition entry in the
profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider
keys named by a settings-file `apiKeyEnv` stay out, which is pi's
34-provider-key problem in a new shape.
Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the
status endpoint's two-part answer, the no-profile refusal, the profile
bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the
permission mode injected via setenv, and the full status bridge -- a
send-and-wait returned signal "stop" from a real turn, and blocked/working
created and cleared an Approvals Inbox item.
Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md
(decisions + honest gaps). Tests: test/deepseek-mode.test.ts,
test/deepseek-cli-resolver.test.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent-image grok step copied /root/.grok/bin/grok onto /usr/local/bin/grok
with cp -L. Newer versions of xAI's install.sh already create
/usr/local/bin/grok as a symlink to that same binary, so the copy failed with
'same file' and the --no-cache rebuild died at the grok layer (2026-08-24).
Stage the copy under a temp name, drop whatever the installer left at the
destination, then move into place - correct against both old and new
installers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-merge follow-ups from the deep review of #334 and #335, so they ship in
the same release as the features.
Tab-layout foundation (#335):
- PUT /api/session-order drops unknown/foreign ids again instead of 400ing
the whole write, in both the owner and the admin path (single-user requests
are the synthetic admin, so that path is the one the browser hits). The
frontend debounces its reorder push and swallows errors, so a session
deleted inside the debounce window silently cost the user the entire
reorder - and the endpoint sits on the stable /api/v1 surface, where the
pre-layout server merged leniently.
- A failed mux restore no longer locks explicit deletions into 500s for the
process lifetime: runSessionDeletion and webviewDeleted degrade to
best-effort without layout coordination, while the automated stale sweep
(runStaleSessionCleanup) stays fail-closed.
- sse-events doc comment: no 'suppressed' hook event exists; hooks stay 8.
- registerSessionWithLayout resolves its owner through ownerLayoutKey()
instead of a hardcoded '@single'.
Vertical rail (#334) - all rail-awareness gaps in sidebar-only predicates,
unified behind the new _isVerticalTabList() (sidebar OR rail):
- Drag-reorder read the insertion side from clientX in the rail, so
before/after was effectively arbitrary on vertical rows; the drag-over
indicators now draw as top/bottom edges there like the sidebar's.
- The active tab is scrolled into view in the rail (Alt+N/palette selection
used to leave the row below the fold).
- Floating subagent/ultracode windows anchor to the RIGHT of rail tabs, and
the connector redraw gates (render tail + strip scroll) cover the rail.
- Server-seeded tabOrientation is applied when the async settings load
resolves, not only at boot, so a fresh device shows the rail immediately.
- The pre-paint script stamps data-tab-orientation and --tab-rail-width
(sidebar-wins and solo carve-outs included), removing the flash of the
header strip on every vertical-mode load.
- The session name font defaults to 12px, the sidebar's historical 0.75rem
size, so installs that never touch the new slider are not restyled.
Also documents the rail in CLAUDE.md (second #sessionTabs host, mover
ordering, the axis-predicate rule) and gives tab-rail-resize.js its
@dependency/@loadorder header. Full gate green (6093 tests); the excluded
browser suite was run by hand - only the known environmental failures
(opencode/codex binaries) remain, identical to pristine master.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The button next to the file preview's close icon was Copy Content, whose
overlapping-pages glyph reads as a pop-out control - and for a PDF or any
media/binary preview it was completely dead: those branches never fill
filePreviewContent, so the click hit an empty-content guard and did nothing,
with no feedback.
There is now a real detach button that opens the previewed file in a browser
tab (raw route for PDFs/images/media/text, the server-converted PDF preview
for docx/pptx), severs window.opener by hand so a blocked pop-up stays
detectable, closes the overlay on success (which also stops any playing
media), and disarms on close so it can never open a stale file. The copy
button now toasts 'Nothing to copy in this preview' instead of staying
silent.
Verified live with Playwright against an isolated instance: button visible
and armed on a PDF preview, file-raw answers 200, clicking opens the URL and
tears the overlay down, text previews keep a working copy buffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent skill's endpoints.md is what other agents read as ground truth, and
three of its facts went stale when grok landed:
- `/api/v1/grok/status` was added to the probe list, but the sentence after it
still said only Pi's response carries `.data.version`. Grok's carries it for
the same reason (a squatted binary name), and an agent that trusts the old
wording has no way to tell a misresolved grok from an absent one.
- the `active-tools` bullet listed grok among the modes it stays empty for, then
claimed in the same breath that `isExternalCliMode` "lists only those five".
- its three source line refs had all drifted: `isExternalCliMode` is now
session.ts:174-183 (it was already wrong before this branch), the external-CLI
early return is session.ts:2261, and TEXT_COMMAND_PATTERN is
bash-tool-parser.ts:89.
CLAUDE.md and architecture-invariants.md counted modes in their Docker-cases and
Web-tabs paragraphs ("any of the five CLI backends", "never a sixth
SessionMode"). Both numbers were already stale before grok (antigravity and pi
had made it seven) and grok is now in the agent image, so the counts are gone
rather than incremented: the invariant those sentences carry is that Docker and
web tabs are not modes at all, which no number has ever helped state. The two
plan docs keep their original wording, being historical design records.
test/workflow-run-watcher.test.ts pinned its fixture's newest activity at
2026-06-14T20:06:40Z and then asked getRecentRunSummaries(100000) to return
it. That argument is MINUTES, so the window is 69.4 days: the assertion
expired at 2026-08-23T06:46:40Z and the file has failed on every branch
since, on a suite nobody had touched. The last green CI run finished at
06:47:43Z, about a minute inside the boundary, which is why it landed as a
surprise rather than a bisectable regression.
The fixture epochs now hang off a RUN_ANCHOR of Date.now() - 601s with every
offset preserved verbatim, so the parsed durations, the ordering and the
live-vs-done discriminators are all unchanged, and the recency filter is
still the thing under test. It just cannot rot again.
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.
Grok mixes two existing shapes and the wiring follows from that:
- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
(--always-approve, grok's bypassPermissions mode; config-level deny rules
still apply on top). The Run button sends it true, like runAntigravity(),
and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
a bare grok spawn is grok's own ask-mode default, which is already safe,
so only a sent config needs the flag forced off. Cron needs nothing for
the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
with mouse support, so it stays OUT of isAltScreenStripMode() and lands
on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
(unmeasured against an authenticated composer; documented fallback is the
'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
also installs a grok bin), so grok-cli-resolver.ts version-probes every
candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
shared with the dependency registry so doctor and run mode cannot drift.
Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.
Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.
Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).
Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scripts/tmux-chooser.sh is deleted. codeman tui replaces it and does the
job better: sc numbered its entries globally but only accepted a single
[1-9] keypress, so sessions 10+ were listed and unselectable, and it
inferred nothing about what an agent was doing. The tui carries the
server's real states, answers permission dialogs, and leaves an attach
with one key.
install.sh no longer creates the tmux-chooser symlink or the sc alias.
It now sweeps both up instead, on update AND uninstall, so an update
cannot leave a symlink pointing at a script this version stopped
shipping. The alias removal is marker-owned: it matches the exact line
the installer wrote, so someone's own 'alias sc=' for another tool is
never touched, and it rewrites through 'cat >' so the profile keeps its
mode and ownership. Verified against three profile shapes.
BREAKING CHANGE: the 'sc' command and the 'tmux-chooser' symlink are
gone. Use 'codeman tui' (and 'codeman tui --list' / 'codeman tui <n>').
codeman tui supersedes the sc bash chooser: it reaches sessions 10+,
carries the server's real states instead of a static list, and leaves an
attach with one key. Every place that told a user to run sc now names the
tui equivalent, including the two wiki pages and install.sh's next-steps
banner. Both wiki pages also carried the wrong detach chord (Ctrl+A D;
the socket's prefix is C-b), which the tui makes moot.
Source comments that explained themselves as "the sc -l replacement" now
just say what they do. docs/tui-plan.md and CHANGELOG.md are historical
records and keep their references.
CLAUDE.md gained "any new `tmux -L` caller through `resolveTmuxSocketName()`"
but architecture-invariants.md, which that bullet points at for the
mechanism, still described only the dataPath() half. Say why the rule
exists there too: the TUI is the first non-server process to shell out
to tmux.
The sc chooser runs a plain `tmux attach-session` and binds nothing, so
F1 does not detach from it; only `codeman tui`'s attach claims that key,
and only for its own duration. The line it replaced was wrong too (the
socket's prefix is C-b, not C-a), so name the real chord and say which
command gives you the single key instead.
The guide and the README both said to detach with `Ctrl+B D`. Beta testing
proved that wrong twice over: tmux binds lowercase `d` to `detach-client` and
capital `D` to `choose-client`, and even the correct letter fails for anyone who
keeps Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. A
tester followed the documented instruction, stayed attached, and exited the
agent to escape.
Both now say `F1`, and the attach section describes what actually happens: the
session strip across the top of the pane, `Alt+1`..`Alt+9` switching without
returning to the dashboard, and `r` to resume a session whose pane has died.
Also corrected: `1-9` switches rather than jump-attaches, `x` confirms with `y`
rather than a typed name, and a new session opens straight into its pane.
`docs/tui-plan.md` is deliberately untouched — it is the design record of what
was planned, not a description of what shipped.
The loose end from 777f974, now explained. Sessions came back from a detach on
`window-size latest` instead of `manual`, and the restore primitive round-tripped
correctly in isolation, so the corruption had to be upstream of it. It was: the
snapshot was taken from state this code had already broken.
`bindSwitchKey` passed a bare `;` between the two commands it wanted in one
binding. That is a command separator to tmux's OWN parser, not an argument: it
ended the `bind-key` and executed what followed immediately. So the binding kept
only `switch-client`, and `set-window-option ... window-size latest` RAN against
every switchable session at attach time — before the sizing snapshot was taken.
Every session was therefore snapshotted as `latest` and faithfully restored to
`latest`.
Proven against real tmux both ways before fixing: a bare `;` leaves the session
on `latest` and stores a one-command binding, while `\;` leaves it `manual` and
stores both commands.
Verified end to end: 7 sessions manual before, 1 latest + 6 manual during the
attach (the attached one follows the terminal, the rest are pre-sized), no dot
padding on a switch, and all 7 back to 120x40 manual after the detach.
This also means the "follow the terminal after switching" half of 777f974 never
actually worked — it was never in the binding.
The overview showed the same session twice, one frame above another, after
switching sessions (reported from the beta with a screenshot).
Claude repaints by ABSOLUTE CURSOR POSITIONING, not by clearing: a 198KB pane
tail carries 1142 `CSI r;c H` and exactly one `CSI 2J`. The replay honoured the
COLUMN of those sequences and ignored the ROW, so a repaint could never
overwrite what came before and was appended instead. That same tail replayed as
FIFTY stacked copies of one frame. The preview shows the last N lines, so on a
short terminal you saw the newest frame by luck and on a tall one you saw the
end of the previous frame above it.
A cursor HOME now starts the buffer over. That is not a heuristic but the
line-based equivalent of what a home means: a full-screen app announcing it is
repainting from the top, with everything on screen about to be overwritten in
place. Only row 1 column 1 counts — any other address is a write position
inside the frame being painted, and resetting on those would erase live
content.
Measured on the real tail that produced the screenshot: 198599 bytes and 50
copies of the welcome frame collapse to 40 lines carrying exactly one.
The old test pinned the append behaviour, including a spurious leading empty
line that the initial CUP produced; both are gone.
The bar now reads "alt+1-9 switch · F1 back to the codeman dashboard", so the
switch keys are discoverable instead of secret. Shown only when those keys were
actually claimed, the same rule the way-out key follows: a bar naming a key
that does nothing is the bug this series started with.
THE DOT GRID. Switching landed in a pane occupying part of the terminal with
tmux's dot fill everywhere else. It was never a size mismatch — the window was
already the right size. `window-size latest` only resizes a window while a
client is ON it, and the sessions behind the tab strip have none until you
switch, so the resize happened AT the switch: tmux painted the newly-available
area with dots and an idle claude had no reason to redraw into it. Every
switchable session is now pre-sized to the attaching terminal, which moves that
repaint to attach time while the user is still looking at the first session,
and the switch binding restores `window-size latest` on arrival so a mid-attach
terminal resize still follows. Measured: 14 consecutive switches across 7
sessions, zero dot-padded rows, against 1-in-6 before.
⚠️ Known loose end, deliberately not papered over: after a detach the window
SIZE is restored exactly but the window-size MODE can come back as `latest`
rather than `manual`. The restore primitive round-trips correctly in isolation
(manual -> presize -> latest -> restore = manual) and no call site in the TUI
or the server sets `latest` afterwards, so the cause is not yet identified. The
practical effect is nil: the remaining client keeps the window at its own size
and Codeman re-pins `manual` on the browser's next resize.
Switching with Alt+N landed in a pane that filled part of the terminal with
tmux padding the rest as a dot grid — reported from the beta with a screenshot
showing the pane in the left half and dots everywhere else.
Codeman pins every window `window-size manual` at the BROWSER's size
(tmux-manager.ts), so no attaching client can resize it. The attach already
lifted that for the session it opened, which is why a plain attach looked
right; `switch-client` then moved the user into a session that had never been
lifted, and the old pin reasserted itself. `window-size latest` now goes on
every session the strip can reach, alongside the bar those sessions already
get, and each one's original sizing is snapshotted and restored on detach.
Verified by round-tripping a session pinned at 120x40 manual: latest 190x49
while attached, back to 120x40 manual after, with no dot rows at either step
and the bar intact at full width after a switch.
The renderer's own FOOTER_KEYS table still said 'jump'. It is only reached when
the app layer supplies no footerKeys, so nothing visible was wrong, but a
fallback that contradicts the live footer is exactly the kind of drift that
turns into a bug report later.
Four faults, all reported at once, and three of them were mine from the last
two commits.
THE HINT VANISHED. Two independent causes. First, a leaked F1 binding: an
attach whose TUI was killed leaves `F1 -> detach-client` in tmux's root table,
and the claim treated "already bound" as someone else's key, so every later
attach fell back to advertising the tmux chord — the bar stopped saying F1
while F1 still worked. A key already bound to `detach-client` now counts as
ours. Second, width: tmux truncates a status line that overflows and drops the
RIGHT-aligned segment, which is the hint. The strip now gets a budget measured
from the terminal's width minus the hint, and it drops tabs from the far end
until it fits. ⚠️ Measured on VISIBLE columns, not format bytes: `#[reverse]`
costs zero columns, and counting it made a strip that "fitted" still truncate
the hint at 80, 100, 120 and 176 columns on a real terminal.
ALT+N DID NOT SWITCH. On the dashboard, a bare digit meant jump AND ATTACH, and
a terminal sends Alt+N as ESC then N: when those land in separate reads —
routine over SSH — the chord decodes as Escape plus a bare digit, so "switch to
tab 2" threw the user into tab 2's pane. A digit now SELECTS, matching what
Alt+N means in the web UI; Enter is how you go in. Inside a pane the keys never
reached the TUI at all, since tmux owns the terminal, so the attach now binds
Alt+1..9 in tmux's root table to `switch-client` — the strip is usable rather
than decorative. ⚠️ The bar is applied to every session the strip can reach,
each highlighting its own tab: with it on the attached session only, switching
landed the user in a pane with no strip and no way out on screen.
⚠️ The leaked-state sweep was missing `status-position`, so it removed the
marker and left the position behind — and with no marker the leftover no longer
matched, making it permanently unsweepable. Found by diffing every session's
options after a detach.
Attaching made every other session disappear: the dashboard is gone, tmux owns
the terminal, and there is nothing left saying what else is running. The attach
bar now carries the session strip, numbered exactly as the dashboard numbers
them, with the session you are in inverted, and it sits at the TOP of the pane
where the web UI keeps its tabs.
The strip is a WINDOW around the active tab, not the whole list, with ellipses
marking each end that is actually cut. The bar is one line shared with the way
out, and that hint is the only instruction a user gets while tmux has the
terminal, so it must never be crowded off; a test drives 20 long-named sessions
through the bar and asserts it survives.
⚠️ The strip is a snapshot taken at attach time and never refreshed. The TUI is
blocked in `spawnSync` for the whole attach so there is no loop to update from,
and tmux's own format language cannot map a `codeman-<hex>` session name back
to a label a human recognises. Slightly stale beats absent.
The way out moves from F12 to F1, which sits beside Esc where a hand backing
out already goes. Verified against BOTH encodings a terminal sends for it:
xterm's SS3 (ESC O P) and PuTTY's default (ESC [ 1 1 ~).
`status-position` joins the snapshot, so a session that had its bar at the
bottom gets it back there on detach along with everything else.
Three separate "why are there boxes" reports, and I fixed them one glyph at a
time instead of as a class, so the next one was always waiting. Grouping the
tester's terminal by unicode block made the rule obvious:
RENDERS Latin-1 (·), Box Drawing (─ │), Block Elements (█ ▛ ▐),
Geometric Shapes (○ ▶), General Punctuation (…), Arrows
TOFU Miscellaneous Technical (⏎ U+23CE, ⏵ U+23F5), the sparse end
of Dingbats (❯ U+276F)
That is an ordinary font, not a broken one, so it is the profile to design
against. The working spinner moves off Dingbats and Math Operators onto
quadrant blocks (▖▘▝▗) — the same block as the `▛█▐` art claude itself draws,
which that font renders fine — and the blocked marker moves off `⚠`
(Misc Symbols, emoji presentation on many terminals) onto `▲`, the block that
already gives us `▶` and `○`.
The preview fold gains claude's own spinner dingbats (✢ ✳ ∗ ✻ ✽ ✴ → `*`) and
`⚠` → `!`. Its animated status line is exactly where a reader looks, so tofu
there is the most visible kind there is.
A test now enforces this as a CLASS: no glyph in the unicode set may come from
Misc Technical, Misc Symbols or Dingbats, with U+2714 the single documented
exception because it was observed rendering on the very font that failed the
others. Verified by scanning a live frame driven with the tester's exact
environment: zero glyphs from any of the three blocks.
Killing demanded the session's NAME typed out in full. That is the right
ceremony for dropping a production database and the wrong one for closing a
pane you are looking at; the beta tester's verdict was "thats stupid, just make
me type Y to confirm". `x` then `y` is already two deliberate keystrokes on a
row the user selected, and the conversation lives in its transcript, which a
kill does not touch.
Everything that is not `y` CANCELS rather than being ignored, so a stray key
closes the dialog instead of leaving a destructive prompt armed and waiting for
whatever gets typed next. Enter cancels too: it is the key most likely to be
hit by reflex, and this is the one dialog that destroys something.
⚠️ Found while verifying the new dialog: it did not name the session. The label
was computed as `row.session.name ?? id.slice(0, 8)`, and `??` falls back only
on null or undefined, so every session the server left with an EMPTY name — all
of them, until the TUI started naming its own — sailed through and the box read
"Kill ?". A destructive prompt that cannot say what it will destroy is worse
than no prompt, and it is now a single keystroke. The caller passes the same
label the LIST shows, so the dialog names the row in front of the user.
The typed-name machinery goes with it: TuiConfirmState.typed, setConfirmInput(),
confirmAccepts() and the 'typing'/'reject' steps are all removed rather than
left as unreachable branches.
Two reports from the same beta screenshot.
Starting a session left the user on the dashboard next to the row they had just
asked for, which reads as the create having silently failed. Starting a session
is a request to WORK in it, so the terminal now goes there as soon as the pane
exists, and the CLI booting is worth watching. If the pane is slow the notice
says so and the row is left selected, exactly as the resume path does.
The footer's `↵` was drawing as an empty box: `⏎` (U+23CE) has poor font
coverage, on the same terminal that renders `·`, `─`, `│`, `○`, `▶` and `✔`
perfectly. It is now U+21B5, from the Arrows block every monospace font ships.
`✋` (U+270B) was worse than a coverage problem: it is East Asian WIDE, so the
renderer, which addresses cells by column, was reserving two cells for it. The
golden frames had the age column shifted a space left to match, which is how
long that had been wrong. It is now `!`, and the frames align correctly.
A test walks the whole unicode glyph set and fails on any entry wider than one
cell, so a glyph that shifts the layout cannot be added again. The comment on
the table spells out both bars a glyph has to clear, because the tier check
answers neither: it asks whether the LOCALE is UTF-8, which says nothing about
whether a font has the glyph or how wide it draws.
Alt+1..9 switches to that session, and `[` / `]` / Tab step through them, so the
muscle memory from the web UI carries over.
Alt+N SELECTS rather than attaches, which is what the web UI's Alt+N does:
switching which tab you look at is cheap and reversible, and the terminal
equivalent is moving the selection and its preview, not handing the whole
terminal to a pane. Bare 1-9 keeps its documented jump-and-attach meaning.
Two of the web UI's chords cannot cross into a terminal, so the nearest
transmittable keys carry them instead:
Alt+[ / Alt+] ESC+[ IS the CSI introducer every arrow key arrives on, and
ESC+] is OSC, so neither chord is distinguishable from a
sequence. Bare `[` and `]` do the job.
Ctrl+Tab a terminal cannot report the Ctrl, so plain Tab carries it.
⚠️ The parser now decodes ESC + a printable character in ONE read as an Alt
chord, and the app replays every chord it does not claim as `escape` then that
character. That fallback is load-bearing, not tidiness: a real Esc landing in
the same read as the next keystroke is byte-identical to a chord, and without
the replay "Esc then q" typed quickly decoded as Alt+Q, matched nothing and was
swallowed. The e2e suite caught exactly that as the dashboard refusing to quit.
A lone Esc is still held and flushed on the caller's timer, which is what keeps
the two separable at all.
A beta tester photographed claude's `❯` prompt and its `⏵⏵` bypass-permissions
marker rendering as empty boxes in the preview pane. Their font has no coverage
for those codepoints while drawing `·`, `─`, `│` and `▶` perfectly.
The glyph TIER cannot help here. It answers "can this terminal do Unicode at
all", which is a locale question, and it correctly says yes for exactly the
terminals this affects. Coverage is per-glyph and undetectable from inside the
process, so the handful of rare glyphs CLIs use as chrome are folded to the
ASCII arrows they already look like, and everything a plain font does render is
left alone.
Scoped tightly: the preview only, never the TUI's own chrome, and skipped
entirely at the `nerd` tier where the user has declared a font that can draw
anything. The table is short and every entry was seen as tofu in a real
terminal rather than guessed at. The fold is length-preserving, so the preview
pane's column arithmetic is unaffected.
Refusing the attach stopped the freeze but told the user to throw the session
away (`x` to close, `n` for new), which loses the conversation. tmux's own
dead-pane screen already says what to do instead: `claude --resume "<name>"`.
The Error card now offers `r` when the row can actually be resumed (claude,
with a conversation id and a working directory), and the footer says so. One
press resumes into a fresh pane and attaches to it, so a dead end becomes
recovery.
⚠️ Three things keep this from becoming the resume runaway that once spawned 35
sessions in 40 seconds. The offer holds a session ID, not a row, and is
re-resolved from the model when the key is pressed: a row captured when the
card opened is stale by then. It disarms BEFORE anything async, so a second `r`
cannot start a second resume. And it routes through resumeSelected(), which
owns the `resuming` flag and ends in attachToSession() rather than the group
dispatch.
⚠️ The `r` branch has to run BEFORE the generic dismiss, because a message
overlay is dismissed by ANY key: without that ordering the offer is consumed as
"some key was pressed" and the card merely closes. `help` keeps the any-key
behaviour, so the two modes no longer share a case.
Verified end to end against a genuinely dead claude pane: card, footer, one
press, one new session, and F12 back to the dashboard.
Three beta rounds died on tmux's native way out, and the last one died on the
instruction rather than the mechanism: "press Ctrl+B, release Ctrl, then d" is,
in the tester's words, very unclear, and holding the modifier through both keys
silently does nothing.
So the way out stops being a chord. The attach claims F12 in tmux's prefix-less
`root` table for its own duration, and the bar reads "press F12 to get back to
the codeman dashboard" — one keystroke, nothing to hold, nothing to release,
no order to get right. F12 because stock tmux ships an empty root table apart
from mouse bindings, and none of the CLIs that run in these panes want the key.
⚠️ The bar names the one key ONLY when the claim succeeded, and falls back to
the chord wording otherwise. A bar advertising a key that does nothing is the
bug this whole series started with, and it must not come back in a new costume.
Same claim rules as the prefix alias: taken only when tmux reports the key
unbound, given back only while it still means `detach-client`.
The chord and the held-Ctrl alias both keep working; they are simply no longer
what the user is told to press.
Reported three times as "Ctrl+B and d is still not working", on a build whose
bar already named the right key. Measured against a live pane: of the three
ways a person types this, only one worked.
Ctrl+B, release Ctrl, then d detaches
Ctrl+B then Ctrl+D (held) nothing happens
Ctrl+B then Shift+D nothing happens
Holding Ctrl through both keys sends 0x02 then 0x04, and tmux ships `C-d`
unbound in the prefix table, so the keystroke is swallowed in silence and the
attach looks frozen. That is not a user error worth documenting around: holding
the modifier is how most people type a two-key chord.
The attach now claims the held-Ctrl form of whatever key detaches (`d` → `C-d`)
for its own duration and gives it back on restore, and the bar advertises it
only once the claim succeeded, so it can never name a key that does nothing.
⚠️ The key is claimed ONLY when tmux reports it unbound, and released only
while it still means `detach-client`, so a binding of the user's own is never
shadowed or removed. The alias is deliberately excluded from the leaked-state
sweep: key tables are server-global, so the sweep cannot tell a leak from a
second TUI's live claim, and a stray `C-d`→detach is harmless either way.
Ruled out along the way, with evidence rather than assumption: the encoding.
tmux negotiates no extended-key mode upstream on attach (no kitty CSI-u, no
modifyOtherKeys, no DECSET 2017), so Ctrl+B does arrive as a plain 0x02 even
from a Claude pane, which has its own keyboard protocol.
Two more from the same beta round, both reported as "basic things are broken".
Attaching to a DEAD pane trapped the user. Codeman sets `remain-on-exit on`, so
a session whose agent has exited does not disappear: the row looks ordinary,
the server still reports it idle, and Enter handed the terminal to a pane that
reads no input. With the detach chord also wrong at the time, that was a hard
freeze with no way out. Enter now probes `#{pane_dead}` first and refuses with
an Error card naming the session and what to do instead. The probe fails OPEN,
so it can never block an attach to a live pane. ⚠️ It also has to paint: the
keypress that reaches attachToSession() has already painted by the time an
awaited probe resolves, so message() alone left the refusal invisible and Enter
looked inert, which is the bug it was added to fix.
A session started from the TUI came out unnamed, because startSession() sent no
sessionName and rowLabel() then fell back to the transcript's first line. A
brand-new session has no prompt to be named after, so the list showed a
perfectly healthy session called "Login interrupted" — the CLI's startup
output, reading like a failure report. Sessions the TUI starts are now named
`w<n>-<case>` like the web UI's, and rowLabel() prefers the case directory over
a scraped prompt for any row with a mux name, since a LIVE pane is identified
by where it runs while a history row genuinely is its prompt.
restore() runs after spawnSync returns, which covers detaching and the agent
exiting inside the pane, but not the terminal dying while attached. Closing the
window or dropping the SSH kills the TUI where it stands, and the bar it
installed stays pinned on the session: the next attach wears a stale bar naming
a different session, and the pane is a row shorter for good. Seen on the beta,
where the tester closed the window instead of detaching.
One sweep at startup, fire-and-forget so it can neither delay the first frame
nor fail a start. Only a bar carrying our own marker is touched, and the marker
is now the single source of the bar's own wording so the two cannot drift; a
user's hand-written status bar on the same session is left exactly as it is.
The session goes back to `status off`, which is how Codeman creates every pane
it owns and the only state this bar is ever applied over.
Two things the attach status bar got wrong, both found in a beta test.
The bar read `Ctrl+B D`. tmux key tables are case-sensitive: lowercase `d` is
`detach-client`, capital `D` is `choose-client`. Pressing what the bar said
opened a client chooser and left the tester attached, with the way out on
screen and inert. The key is now READ from `list-keys -T prefix` the same way
the prefix already was, rather than hardcoded, so a rebound tmux is followed
too and the label cannot drift from the binding again. It never goes through
formatPrefixKey(), which uppercases.
The bar also rendered as a full-width bright green slab. Only `status-format[0]`
was styled, so tmux's stock `status-style` (`bg=green,fg=black`) stayed
underneath it and won; `#[reverse]` on top could not undo it. `status-style` is
now set explicitly to `bg=default,fg=default` and snapshotted/restored with the
rest, so the bar sits on the terminal's own background and reads as a hint
line.
Tests pin both: that the chord ends in lowercase `d` and never ` D`, that a
rebound key prints verbatim, that `status-style` is part of the banner, and
that parseDetachKey() picks `d` out of verbatim tmux 3.4 `list-keys` output
while ignoring `detach-client -a`/`-P`, which act on other clients.
Three things the first beta test surfaced.
1. Attaching from a terminal of a different shape showed the pane clipped to the
browser's size, with tmux's dot padding filling the rest. Codeman pins every
window it owns to `window-size manual` at whatever the web client reports
(tmux-manager.ts), so no attaching client can resize it. The handoff now
brackets the attach with `window-size latest` and restores the snapshot on
detach. `latest`, rather than a one-off resize to our own size, is also what
lets a terminal resized MID-attach follow along: tmux recomputes on every
SIGWINCH while the TUI is blocked in spawnSync and cannot.
2. Nothing on screen said how to get back out, because Codeman keeps the status
bar off on its panes (the web UI carries that information around the terminal
instead). The tester exited the agent looking for the exit, leaving a dead
pane. An attach now wears a `status-format[0]` bar reading "<prefix> D
detach, back to the codeman dashboard", with the prefix READ from tmux rather
than assumed, and the session's options are put back exactly as they were on
detach. One option, not status-left/status-right, so tmux draws no window
list beside it; `reverse` so it inherits the terminal's own theme. Restoring
an array option unsets the BASE name, since dropping the `[0]` index leaves
an empty array, which renders as a blank bar on a session that had one. The
help overlay names the chord, and the dashboard confirms the detach.
3. Enter on a RECENT row said resuming was not wired up. It now creates a
session carrying that conversation (`resumeSessionId` plus `/interactive`,
the path the web UI's Resume Conversation list already uses), in the
directory it ran in and under its old name, then attaches to it.
The attach mechanics deliberately sit in a method the group dispatch cannot
reach, plus a re-entrancy flag: routing resume back through the Enter handler
re-dispatched on "this row is RECENT" and spawned one session per pass, 35 in
about 40 seconds on the beta before it was killed. test/tui/tui-e2e.test.ts
pins one press to one session with a pane that never appears, which is
exactly the case that looped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both of the dashboard's periodic reads hit endpoints that are far more
expensive than their cadence assumed, and the cost lands on the SERVER's
event loop, so it is paid by every browser client too.
`GET /api/sessions/unified` is ~550ms against 11 live sessions: it scans
every Claude transcript plus the lifecycle log, uncached, and republishes
the search index. `scheduleRefresh()` was a 250ms trailing debounce with no
floor, and a queued refresh re-ran the instant the previous one returned
(by recursing, which also chained one pending promise per iteration), so a
stream of events paced the refetches at the endpoint's own latency: with
`session:updated` broadcast per session per 500ms while anything is
working, the scans ran back to back. `resyncDelayMs()` now keeps ambient
refetches 3s apart, measured start-to-start. The user's own actions call
`refresh()` directly and are unaffected, so what this paces is only
"notice what changed elsewhere".
`GET /api/sessions/:id/terminal` is ~80-100ms: two `execSync` tmux calls,
then the whole byte buffer normalized before the tail is taken. It was
polled every second for as long as a live row was selected. It now backs
off 1s, 2s, 4s, 5s while consecutive reads change nothing, and resets to 1s
on any change, when the selection moves, when this dashboard sends input or
answers a dialog, and on return from an attach. A pane that is printing is
still read every second; a pane at its composer is not.
The poll also kept running in three places it had nothing to draw for: the
whole time the user was attached in tmux (an attach can last hours), and
behind the message overlays that an async action opens (answered, killed,
started), which are not keystroke-driven and so never reached the
`afterInput()` path that stops it. `setInterval` becomes a chained
`setTimeout`, since the delay now varies.
Measured against the live server, same idle row selected, 25s window:
22 tail reads before, 5 after. With a working pane selected it stays at 22,
which is the intended cadence for a pane whose output you are watching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`TuiModelStore.confirmSatisfied()` and `approvalFor()` had no caller
outside their own tests. The first one mattered: it answered "does the
typed text authorize this kill?" with an exact name match, while the rule
actually consulted (`confirmAccepts()` in tui-app) also accepts the
8-character id prefix a mux name carries. Two divergent answers to one
question, the stricter one unreachable and waiting to be picked up by
mistake. knip cannot see class members, so the dead-code sweep never
flagged either.
The tests they existed for now assert observable state instead, and the
approvals one got stronger on the way: it checks that a session id coming
back does not inherit the dead session's dialog, which is the invariant
`removeSession()` is actually keeping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`TuiSessionRow` declared `lastSubmitAt`/`inputTokens`/`outputTokens`,
`stateSince()` ordered the WORKING group by the first of them and
`renderRowLines()` painted the other two, but nothing ever filled any of
them in: the unified list carries none, and the `session:updated` payload
that does was discarded (an event only schedules a refetch).
So a running turn was dated by its SESSION's creation instead. Measured
against the live server before the fix: w65 (created 21h ago, turn started
one minute earlier) outranked w67 (created 15 minutes ago, turn started
five minutes earlier), the reverse of the rule docs/tui.md states, and the
elapsed column read `21h` for a turn a minute old. The token column was
unreachable code for the same reason.
`fetchLiveSessionMetrics()` reads the three fields from `GET /api/sessions`
and `applyLiveMetrics()` folds them onto the rows. That route answers from
the server's cached LIGHT state (no terminal buffers): 10-20ms measured,
against the ~550ms the unified list in the same `Promise.all` already
costs, so it is cheap enough to ride every refresh. It is best-effort like
the approvals and tmux reads beside it, because losing the anchor is
better than losing the list.
A ZERO is treated as unknown rather than merged: `stateSince()` reads
`lastSubmitAt ?? createdAt` and 0 is not nullish, so a merged 0 would date
every never-submitted session to the epoch.
The snapshot path gets the same merge, or `codeman tui --list` would number
the WORKING group differently from the dashboard that `codeman tui <n>`
indexes into.
Verified live: working rows now show 28m/8m (turn age, tokens 280.5k/65.2k)
where they showed 21h/34m and no tokens. The e2e assertion fails on master's
wiring with `[*] 10m` against a session that pressed Enter one minute ago.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The data-dir half was already spelled out; the socket half only lived in
a function docstring, and the TUI is the first code that shells out to
`tmux -L` from a process that is not the server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inventory test predates the `tui` command, so a rename or an
accidental removal would have gone unnoticed: it now asserts the command,
its `-l`/`--list` flag and its optional position operand.
The digest and search-result lines joined their halves with an em-dash,
which the repo's own convention rules out, so both now use the middle dot
the surrounding lines already use. The one em-dash left in `src/tui/` is
load-bearing: `search-service.ts` builds a session snippet with it, and
the pattern that strips the repeated label has to match it.
Also moves `buildSearchEntries`'s doc comment back onto
`buildSearchEntries`; it had ended up stacked above a helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`mark()` had no callers (knip's only finding on this branch), and the
renderer's fallback help list advertised `r` resume, which is deferred
with the rest of phase 3: a help screen naming a verb the build does not
implement is worse than no help.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user guide covers what the dashboard is (and is not), the two
non-interactive fast paths, the four groups and their ordering, the full
keymap, what answering an approval does server-side, and the SSH/narrow
and degraded cases. The example frame is a real 100x30 capture against
the E2E fake server, not a drawing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A server that comes up mid-run was upgrading the header's hostname and
version but not its chip, which then stayed blank until the next telemetry
event. Also swaps a typographic apostrophe out of a preview error, which is
not renderable on the ASCII glyph tier.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two small honesty fixes at the edges: a server that goes down leaves the
dashboard holding prompts nothing can classify any more and whose answer
route is unreachable, so degraded mode clears them; and a resize can cross
the narrow breakpoint, where there is no preview pane to poll for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured against a live Claude pane: an Ink TUI paints by ROW and emits
almost no newlines, so dropping cursor-position sequences collapsed a whole
screen into one unreadable line, and a tail cut mid-sequence printed the
remains of it (";1H") as text. Now a jump to column 1 starts a display line,
a jump inside a row moves the write position (capped, since a stream may
address a column no terminal has), and a severed CSI head is dropped before
parsing.
The preview is readable against a real session as a result: tool calls, the
working line and the composer all land where they belong.
Also drop the repeated session name from a search row, whose snippet opens
with the name the row already shows in its first column.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fake API server grows the routes the dashboard now calls (terminal tail,
input, approvals answer, search, away digest, plan usage on status), and the
new cases assert on what the server RECEIVED rather than on the frame: the
prompt arrives as one line ending in a carriage return, and the answers as
the exact action and option digit.
Also covered: the tail refreshing in place, the search overlay selecting a
live session, the digest rendering, one bell for an item announced twice,
and the 409 path reported as "no longer on screen".
The plan-usage chip is punctuated with the glyph tier's separator, so an
ASCII terminal no longer gets a stray middle dot in the header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard stops being read-only. The selected session's tail is polled
once a second while the plain list has focus and the layout is wide, and an
unchanged tail never reaches the model, so a quiet session costs no repaint.
A row with no live buffer says so instead of polling forever.
Keys: y/n and the parsed digits answer the selected session's dialog through
`POST /api/approvals/:id/answer` (never a blind keystroke: that route
re-captures the pane and 409s when the dialog has moved on, which the TUI
reports as "no longer on screen"); `p` opens a one-line composer aimed at
the selected session; `/` searches with a 250ms debounce and Enter switches
to a live session result; `g` shows the away digest. A new prompt rings the
bell exactly once, tracked by item id so a repaint or a refetch cannot
stutter, and the plan-usage chip rides `GET /api/status` plus its telemetry
event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The preview pane now leads with the pending dialog when the selected session
has one: the question, the options with their digits, and the keys that
answer them, red for a dialog and yellow for a waiting prompt. The card is
capped at half the pane, because the tail is why the pane exists.
Around it: a header badge counting prompts that need a human, a preview
title that sacrifices the path rather than the state word, the footer
becoming the composer line while one is open (with the cell the terminal
cursor belongs in, so it can be shown there and hidden everywhere else), and
the search and digest panels as overlays with a stable width.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The store gains the three overlays phase 2 needs, each taking the keyboard
when it is set and all of them cleared together by closeOverlay(), plus the
pure flattening of `GET /api/search`'s typed groups into rows a cursor can
move over: headers are chrome, and only a session that is on the list counts
as selectable, since a history hit has no row to move the cursor to.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three small pure modules the phase-2 verbs are built on:
- tui-composer: the single-line editor behind `p` and `/`, holding text as
code points so a cursor can never split a surrogate pair, with the scroll
window derived from the width rather than remembered.
- tui-approvals: what an approvals-inbox item's card says, which keys are
live for it (a digit answers only when the server parsed that option, and
an idle prompt answers to none of them), and which ids the bell has not
rung for yet.
- tui-digest: the away digest as compact lines, counts first and one line
per entry, with a capped tail per section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spawns the real command in a pseudo-terminal against a fake API server
(canned status/unified/approvals plus an SSE stream the test pushes
into), which is the only way to cover raw-mode key decoding, frames
reaching a terminal, SSE-driven refresh and the exit sequence that has to
restore the user's screen.
Two details the assertions depend on: frames are addressed absolutely
rather than newline-separated, so the parser takes the last COMPLETE
frame (the pty delivers one in several chunks, and reading a half-written
frame would be racy), and it reads the sidebar column only, or a name
echoed in the preview pane could answer for a row.
The child gets its own data dir and a tmux socket name nothing runs on,
so nothing here can see or touch the machine's real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`codeman tui` opens the dashboard, `codeman tui --list` prints the
numbered list and exits (the `sc -l` replacement, plain when piped) and
`codeman tui <n>` attaches straight to a row (the `sc 2` replacement).
Both fast paths short-circuit before any screen setup, and both refuse
the numbers path without a terminal instead of half-opening a UI.
Bare `codeman` still prints help: the web UI stays the primary surface.
The TUI module is imported lazily so the other commands do not pay for it
at startup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The IO half of src/tui: it owns the terminal, the timers, stdin and the
tmux handoff, and every decision it makes that is a function of its
inputs is an exported pure helper with unit tests (attach planning, the
typed kill confirmation, keymap selection, the repaint test, degraded
rows).
What it does: live session list over the unified API with SSE-driven
resync (debounced, with a 2s poll fallback the client asks for), cursor
and 1-9 navigation, attach and return, kill behind a typed confirmation
that refuses history rows and the session hosting the TUI, a new-session
case and CLI picker over quick-start, and degraded mode straight from
tmux when no server answers, re-probing so a server that starts upgrades
the dashboard in place.
Restoring the terminal is the part that has to be bulletproof: leave() is
idempotent and runs from normal quit, SIGINT/SIGTERM, a process exit hook
and prepended fatal handlers (src/index.ts already handles those by
exiting, so a listener registered after it would never run).
Attach is a handoff, never a proxy: the screen is restored and tmux gets
the real terminal. Inside tmux on the same socket there is nothing to
hand off to, so it issues switch-client and exits.
The preview pane, approvals answering, the prompt composer, search and
the digest are the next step; the region renders a placeholder rather
than pretending to load something.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The footer and the help overlay held the plan's full keymap, which would
advertise verbs (prompt, search, digest, answer, resume) that the build
does not implement yet and teach users that the TUI ignores keys. Both
now take their entries from the render options when the caller passes
them; the built-in lists stay as the fallback.
The picker overlay windows its items around the cursor rather than
clipping them, so the selected case stays visible in a long list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app layer repaints on state change, so the store has to be able to
say that something changed: `revision` is bumped by every mutating
method, and the repaint test compares it against the last painted frame.
Without it an idle dashboard would either redraw on a timer or go stale.
Three additions come with it, all optional so nothing existing changes
shape: `TuiSessionRow.muxName` (the unified list carries no mux name, so
the app fills it in from the local tmux enumeration and a row without one
cannot be attached), a `new-session` UI mode, and `TuiPickerState`, the
one-column chooser behind `n`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything the dashboard needs from outside the process, behind one typed
surface, so the app loop stays a loop. It is a client of the running server and
nothing else: rows come from the unified list, blocked states from the
approvals inbox, and answering goes through the endpoint that re-captures the
pane and refuses with a 409 when the dialog has already been answered in tmux.
That refusal is a typed result rather than an exception, because a human
beating you to a prompt is normal operation.
Discovery mirrors the daemon probe (`CODEMAN_API_URL`, else loopback on
`CODEMAN_PORT`, self-signed TLS accepted) and credentials come from where
`codeman attach` already reads them. An explicit port outranks the ambient
`CODEMAN_API_URL`, which every managed session exports: a caller that named a
port must not be redirected at whatever server owns its shell.
Input is single-line and `\r`-terminated at this layer, so no caller can strand
text on an unsubmitted composer, and each send is tagged for the server's
exactly-once path. The event stream defaults to a `?sessions=` filter that
matches nothing, which drops the terminal firehose while lifecycle, hook and
approval events still arrive. A silent-but-open stream is caught by a watchdog
rather than a socket error, since that failure mode reports nothing at all.
With no server answering, sessions are listed from tmux on the instance socket
(argv, never a shell string) and decorated from a read-only peek at state.json,
which keeps the "the server died, get me to my sessions" path alive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node has no EventSource, so the live-update stream is read as raw bytes and
decoded here. Three details are what the parser exists for: a TCP read can end
between the CR and the LF of a CRLF, so a trailing CR is held back rather than
dispatched; the tunnel padding the server appends after a frame is a comment
with no blank line after it and must not split anything; and the keepalive is a
NAMED event, because an SSE comment is invisible to a browser client by spec.
Event classification lives here too, as a set rather than a prefix test:
`session:terminal` is most of the stream and the preview pane pulls its own
tail, so it is deliberately not a resync trigger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The socket name was computed inside tmux-manager, which the TUI cannot import
just to learn which `-L` name its degraded-mode listing belongs on (that module
is the server's tmux driver, not a lookup table). The resolver moves next to
`dataPath()`, where the other half of the instance identity already lives, so
both processes agree by construction instead of by a copied default.
Behaviour is unchanged: the override still wins only when it is a name that can
be passed to `tmux -L` safely, and TmuxManager keeps warning about one that
cannot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One absolutely-addressed line per row, each closed with an erase-to-end, so
nothing scrolls and a repaint cannot leave the previous frame's tail behind.
The caller wraps the result in synchronized-output brackets; that is an IO
decision and stays out of the renderer.
Color is passed in rather than detected. chalk's detection is right for the
one-shot CLI but would make a frame non-deterministic, so the palette is raw
SGR in the same semantic roles cli-style uses, and `color: false` emits nothing
but the cursor addressing, the session's own colors in the preview included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Below 72 columns the preview pane is dropped and rows take two lines, the
constraint the `sc` chooser was built around and the reason it is still usable
on a phone; above it a clamped sidebar carries the list and the preview takes
the rest.
Every region is clamped to a non-negative size, so a 5x5 terminal degrades to a
header instead of handing the renderer negative widths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows are the ones GET /api/sessions/unified already returns and blocked states
are the items the approvals inbox already parsed, both imported as types only
so a CLI process pulls in neither the server nor node-pty. Classification
speaks the web UI's language (red blocked, yellow waiting, green working) so a
user with both surfaces open never has to translate between them.
Groups order by how long a session has been in its state, which is why WORKING
anchors on the pane's last Enter: a working pane repaints about once a second,
so its last-activity stamp always says "now".
Selection is tracked by session id, never by row index: rows re-sort under the
cursor whenever a session starts working or an approval lands, and an
index-tracked cursor would quietly move the selection to another session
between two keystrokes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decodes printable UTF-8, the control keys, arrows in both CSI and SS3 forms and
SGR mouse reports out of a byte stream that can tear anywhere, so a sequence
split across two reads decodes the same as one that arrives whole.
A lone ESC cannot be told from the start of an arrow key by looking at bytes,
so the parser holds it and the caller resolves it with flush() once its
disambiguation timer fires. Unknown sequences are swallowed: a stray CSI must
never reach a prompt composer as typed text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The preview pane shows a session's raw terminal stream, so it needs the tail
reconstructed rather than emulated: SGR survives, cursor steering and OSC do
not, and a carriage return returns to column 0 so a spinner that repaints its
line 200 times contributes one line instead of 200.
Widths count East Asian Wide characters as two columns, which the clip and pad
helpers rely on to never cut a wide character, a code point or an escape
sequence in half.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The entry dates from an abandoned prototype (0.1427) and would have kept the
real TUI modules untracked while `git status` stayed silent about it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`codeman attach <path>` posts an attachment card for a local file; it
was described as attaching a Claude hook context. And Codeman never
overrides the tmux prefix for local sessions (only remote-SSH and docker
panes get C-q), so the detach hint is Ctrl+B D, matching the chooser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The file asserted against a hand-written fixture array with its own
argument parser, so it could not see a command being renamed, losing an
alias or disappearing, and it described a `tui` command that does not
exist. It now walks program.commands: names, aliases, subcommands,
option flags, operands, descriptions, and a guard against registering a
name or alias twice at one level.
Assertions are "at least this exists", so a new command (including the
tui one this plan adds later) passes without editing the test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The startup banner is now the only one (the CLI printed a duplicate) and
is painted like the rest of the CLI. The non-loopback-without-password
warning was plain console.warn while the CLI's copy of the same warning
was yellow; chalk degrades off a TTY, so journald and web.log stay free
of escape codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- doctor is colorized through the ReportStyle hook: verdict glyph and
failing status text painted, paths and hints muted, versions left
alone. `doctor --json` still prints raw JSON.
- `codeman web -d`, `web --stop` and `service install` block for up to
30s polling /api/status; each now runs under a spinner instead of a
silent terminal.
- `codeman reset` asks a real y/N question on a TTY. Non-interactive
callers keep the old "Use --force to confirm." refusal, so no script
can be answered by a question it cannot see.
- `codeman list` was a drifted copy of `codeman session list`; both now
call one renderer, with the shorthand opting out of the stopped and
web-server sections.
- `web` no longer prints its own "running at" line: the server prints
one, and unlike this one it also covers the daemon and service paths.
- every chalk call goes through the palette, so the CLI has one place
where colors are decided.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Antigravity CLI" is 15 characters and the hardcoded padEnd(14) pushed
that whole row one column right. Widths now come from the widest cell.
The header always said the CLI layer may colorize, but there was no way
to: renderTable now takes an optional ReportStyle whose hooks are
identity by default, so the module still decides nothing about color and
its output stays byte-stable. Padding is applied outside the paint, so a
row with no path detail ends at its status text instead of trailing
spaces inside a color run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One vocabulary for everything the codeman CLI prints: semantic palette,
the glyph set the commands already used, heading/rule/kv, width-aware
table layout, a stderr spinner and a y/N confirm.
Color detection stays chalk's, so NO_COLOR and non-TTY degradation keep
working with no second detector to disagree with it. The layout math and
glyph selection are pure and exported, which is what lets the dependency
report reuse them while staying color-free.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-merge follow-ups for PR #329 (shared CLI executable resolution):
- Negative-cache resolution misses with a doubling backoff (1min -> 5min
cap, cliResolveRetryDelayMs, mirroring claudeVersionRetryDelayMs): the
shared resolver cached success only, so a missing CLI re-ran the whole
chain - ending in a synchronous interactive login-shell spawn bounded by
the 5s EXEC_TIMEOUT_MS - on every /api/<cli>/status request and Run
attempt, stalling the event loop each time, forever. Success still caches
for the process lifetime, so an installed CLI is picked up within minutes
without a restart. Tests drive the backoff via an injectable clock
(createCliExecutableResolver `now` option, threaded through the
createPiResolverForTest / createAntigravityResolverForTest wrappers).
- Pass killSignal: 'SIGKILL' on the resolver's login-shell spawn and on the
pi/claude --version probes: execFileSync's timeout only SENDS the kill
signal and then keeps waiting for the child to exit, and interactive bash
ignores SIGTERM, so a login shell stuck in a blocking .bash_profile
survived the timeout and blocked the server permanently.
- Restore test hermeticity (PR #329 deleted pi's VITEST guards, and one
test pinned the deletion): under vitest the production resolver host now
replaces un-injected IO primitives with inert stubs - no real PATH
scanning, no login-shell spawns - and probePiVersion never executes a
`pi` candidate again (`pi` is a generic binary name, so route tests
hitting /api/pi/status executed whatever binary the machine carried).
Tests opt in through the runCommand/isExecutableFile injection hooks or
allowRealIoUnderVitest for real-filesystem fixtures. The deletion-pinning
test is replaced by behavioral pins, including a real-executable fixture
in the new test/pi-cli-resolver.test.ts that fails loudly if the pi gate
is ever removed again.
- Wire the six get*NotFoundMessage() exports (previously dead) into their
intended call sites: the createSession throws in tmux-manager and the
availability gates on POST /api/sessions and POST /api/quick-start in
session-routes, replacing a third hardcoded copy of the text. A not-found
error now names where resolution looked (server PATH, login shell,
checked directories). npm run knip no longer reports any unused export
from the resolver modules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-merge follow-ups for #328 (GET /api/system/repo-status):
- Event-loop blocking: every git invocation in repo-status.ts is now async
(promisified execFile), never execFileSync — the per-remote ls-remote +
fetch could hold the event loop (SSE, PTY streaming) for up to ~60s per
request. The whole computation is single-flight with a 45s TTL cache
(createSingleFlightCache): concurrent requests share one in-flight
promise, a fresh result is served without spawning git, and a rejected
compute is never cached. Route handler shape and response fields
unchanged; remotes still processed sequentially (concurrent fetches in
one repo contend on ref locks).
- Credential disclosure: the redaction from git-clone.ts is extracted as
exported redactGitCredentials() (sanitizeGitOutput now uses it) and
applied via redactRemoteStatus() to every remote card's url and error
string, so a scheme://user:token@host remote URL (or git stderr echoing
it) never reaches a client.
- Non-interactive env: runGit() now uses the shared gitNonInteractiveEnv()
instead of a partial GIT_TERMINAL_PROMPT/BatchMode env, also closing the
GIT_ASKPASS/SSH_ASKPASS/SSH_ASKPASS_REQUIRE/DISPLAY/GCM_INTERACTIVE
prompt paths.
- Upstream parse bug: a local-branch upstream (@{upstream} with no slash,
e.g. after `git branch -u otherbranch`) made slice(0, indexOf('/')) into
slice(0, -1) and yielded garbage like "maste". parseTrackingRemote()
(pure, unit-tested) returns null for it, and the bare ref is dropped so
it cannot be mistaken for a remote-tracking ref downstream.
Tests extended in test/repo-status.test.ts (parseTrackingRemote,
redactGitCredentials/redactRemoteStatus, createSingleFlightCache
single-flight/TTL/rejection semantics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three post-merge fixes for the external-CLI response viewer:
- ?context=full blocks now carry role ('user' for prompts, 'assistant'
for response/status/tool). The frontend's loadFullContext() renders
via msg.role, so the roleless blocks lost the "You" badge and every
turn rendered as the agent. kind/label/text are unchanged and the
frontend needs no change.
- normalizeDividerStatusLine() dropped its backtracking regex
(/^[─-]+\s*(.+?)\s*[─-]{3,}$/): the lazy middle went catastrophic on
a long dash run without a 3-dash tail (measured 15.5s at 4,000 chars,
minutes at 10,000), and pane text is agent-controlled with buffers up
to 32MB. Replaced by a linear counter walk with the identical accept
set and captured content, pinned char-for-char against the old regex
by a brute-force corpus test plus a hostile-input regression test
that fails by timeout with the RegExp version (same approach as the
glob-matcher hardening in 68ae9a8).
- 'pi' joins EXTERNAL_CLI_MODES: pi sessions had the identical
empty-viewer symptom the transcript branch exists to fix. The list
stays a local duplicate of isExternalCliMode() (importing session.ts
would drag node-pty into the pure module); a new exhaustive parity
test asserts the two mode sets can no longer drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#325 renamed _sessionUsesServerMouseStrip to _shouldReportMouseToCli and
added the server-observed cliMouseTracking half of the gate, which also
turned codex tap reports from measured no-ops into not-sent-at-all. The
invariants paragraph still described the old name and the old behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirming an AskUserQuestion left its tab flowing red for the rest of
the turn (owner report: ~8 minutes on a running session, with no dialog
anywhere on screen). Two separate bugs, both live-verified.
The re-capture erased the evidence the staleness check runs on. Claude
Code fires the Notification behind the dialog (measured 6-7s on v2.1.237,
documented up to ~30s), so the 600ms re-capture routinely lands on a
frame the user has ALREADY answered, parses nothing, and applyCapture
overwrote item.options with undefined. A MISSING options is how "we never
could read this dialog" is expressed, and those items stay answerable by
design, so a cleared field was indistinguishable from a never-parsed one
and the item became permanently unsweepable: it survived every
GET /api/approvals and every page reload, cleared only on `stop`, and
still accepted an answer, sending a bare `1` into a composer with no
dialog under it. applyCapture is now ADD-ONLY for options.
Nothing ran the staleness check while a page was open. It lived only in
GET /api/approvals, which seedApprovals() calls on init and reconnect, so
`stop` was the first thing that ever cleared an answered dialog. The
`working` signal now runs the pane-VERIFIED variant (resolveIfDialogGone
-> verifyStillAnswerable): the heuristic only decides when to look, the
screen decides the outcome, so the existing "working can flap" rule is
respected.
A frame that parses no options is now conclusive in two cases, and only
those, so an unreadable capture still keeps the alert: the item once
parsed options, or the frame shows Claude actively running a turn. A
modal dialog BLOCKS the turn, so the two cannot coexist - measured, a
live-dialog frame carries neither the elapsed-timer spinner nor the
"esc to interrupt" footer, which the dialog replaces with "Enter to
select". That second signal is reached by a delayed staleness pass (3s)
scheduled alongside the re-capture, which closes the late-hook case where
the prompt is answered before the hook lands: nothing ever parses, `stop`
may have gone by already, and the alert outlived reloads until the 12h
TTL. The pass is deliberately later than RECAPTURE_DELAY_MS, whose whole
reason for existing is that the hook can beat Ink to the screen.
Frontend: _onHookElicitationComplete cleared only the elicitation entry,
but an AskUserQuestion arrives as permission_prompt, so it was clearing
the wrong alert; it now clears both, matching the server's kind-agnostic
APPROVAL_RESOLVING_EVENTS.
Verified end to end on an isolated beta instance, not just in unit tests:
before, resolution could only come from the stop route (approval:resolved
always immediately preceding hook:stop); after, it arrives from the new
paths, and a simulated late hook resolves at +3.12s with no stop, no
working signal and no GET, while the pane is still working. Tests use
frames captured off a live pane and each new one was confirmed to fail
against the old behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while verifying Auto Copy in a browser: a plain left click in a
claude/codex/gemini pane sent a synthetic SGR mouse report into the PTY
whether or not the program in that pane had ever enabled mouse tracking.
When the pane holds a plain shell (the CLI exited, or a shell was started
inside a session of that mode) readline prints the report as literal text
and it garbles the next line typed:
$ [<0;88;20Mecho hello
bash: 0: No such file or directory
The cause is that the browser could not know. The full strip
(isAltScreenStripMode) removes the mouse DECSETs from the stream, so
xterm's modes.mouseTrackingMode is permanently 'none' for those modes and
_sendSyntheticSgrTap() hand-encodes reports to stand in for xterm's own
encoder. With no state to consult it had to do that on every click.
What the strip removes, the server now remembers.
_recordStrippedMouseMode() records each sequence as it is stripped,
toState() publishes it as cliMouseTracking, and the browser's
_shouldReportMouseToCli() (renamed from _sessionUsesServerMouseStrip)
requires it at all three report sites: the desktop click, the touchend
tap, and the mobile tap classifier.
Details that are easy to get wrong:
* Only the tracking modes count (1000/1001/1002/1003). 1005/1006 select
an encoding and 1007 is alt-scroll; a CLI that picks SGR encoding
without turning tracking on is not asking about clicks, and counting
those would put the stray reports straight back.
* Modes are held in a Set, so a TUI disabling a mode it never enabled
cannot clear the ones that are really on.
* The change broadcasts immediately instead of through
broadcastSessionStateDebounced: the flag flips when a dialog opens, and
the user can click that dialog well inside the 500ms debounce window.
* It fails toward silence. After a server restart the flag is false until
the CLI re-emits its DECSET, which tmux does at client attach.
Verified against a live claude 2.x session: the CLI holds a tracking mode
on continuously, so its clicks are still reported byte for byte as
before, while a bash prompt in the same stripped mode now reports
nothing and types cleanly. The flag also propagates live over SSE in both
directions, checked by toggling ?1002h/?1002l from inside the pane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
App Settings > Terminal & Input > Selection & clipboard > Auto Copy
Selection (`autoCopySelection`, per-device, default OFF). With it on,
highlighting text in the terminal copies it: mouse drag, double-click
word, triple-click line, and the phone long-press selection. Ctrl+C is
untouched and still copies on demand.
Three things decide the shape of it:
* It fires at the END of a gesture, never in onSelectionChange. That
callback runs for every cell a drag crosses, so copying there would be
one clipboard write per mouse move. It only arms a pending flag; a
document-level mouseup listener flushes, and the touch path calls the
flush itself because it preventDefaults its touchend and no mouseup
ever arrives there.
* The flush is synchronous inside the handler, because both clipboard
paths need user activation: Firefox gates navigator.clipboard
.writeText on it, and execCommand('copy'), the fallback the plain-HTTP
LAN install lands on, has to run in the gesture's own task. A timer or
a wait for onSelectionChange loses it, invisibly in Chrome.
* It deliberately does NOT do what copyTerminalSelection() does. That
one clears the selection (so a second Ctrl+C is an interrupt) and
focuses the terminal. Clearing would make text vanish under the cursor
that just highlighted it, and focusing opens the on-screen keyboard
over it on a phone. Focus is instead restored to whatever held it,
which only matters for the execCommand fallback.
Guards are pure in decideAutoCopy() (constants.js): off, blank or
whitespace-only text, and a 1M-char cap, since a drag off the top of the
viewport autoscrolls and one gesture can sweep the whole 50k-line
scrollback. Past the cap the copy is refused rather than truncated, with
a toast pointing at Ctrl+C.
Feedback is silent on success except once per page load, so a feature
that works by doing nothing visible can still be told from a dead
toggle; failures and refusals toast, throttled to 10s.
Per-device on both counts the settings rule requires: in `displayKeys`
and absent from the .strict() SettingsUpdateSchema, because clipboard
access differs by device and by origin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last two items of #322: copyTerminal() copied the entire buffer but
was wired to no button, shortcut or call site anywhere, and it wrote
through navigator.clipboard directly, which is undefined on the
plain-HTTP LAN install, so it would have failed there even if it were
reachable. Everything that actually copies goes through
copyTerminalSelection() and _copyText's execCommand fallback; whole-
buffer copy, should anyone want it, is a selectAll() away from that
same working path.
Closes#322
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Files search compiled the user's query into a backtracking RegExp:
'*a*a*a...' became '^.*a.*a.*a...$', the classic blowup, evaluated
synchronously against every walked path — a pathological query could
freeze the event loop for the whole server (and every user of it in
multi-user mode). /api/search stays regex-free for exactly this reason.
Globs now match through a two-pointer wildcard walk, O(text · pattern)
worst case, with a 256-char query cap bounding the pattern side; an
overlong query compiles to null, the same answer as an empty one.
Semantics are unchanged (anchored, case-insensitive, * spans slashes)
and the existing tests pass untouched; the pathological pattern gets a
test that fails by timeout with the RegExp version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new local-echo-overlay gotcha landed as a list item but left the
xterm-zerolag-input entry below it without its leading '- ', splitting
the Common Gotchas bullet list in two.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the four advisories that reach the production dependency tree. The
other 16 npm audit reports are devDependencies-only (Remotion, Puppeteer,
postcss, the eslint/tsx toolchain) and never ship to users.
- @fastify/static 9.1.3 -> 10.1.3 GHSA-8pvw-jcv7-9cmj (authz bypass via
non-canonical URL paths). Covers <=10.1.1, so all of 9.x is affected and
the fix exists only on the 10.x line.
- find-my-way 9.6.0 -> 9.8.0 GHSA-c96f-x56v-gq3h (HTTP/2 DDoS)
- fast-uri 3.1.2 -> 3.1.5 GHSA-v2hh-gcrm-f6hx (host confusion)
- brace-expansion -> 5.0.9/1.1.18 GHSA-3jxr-9vmj-r5cp (expansion DoS)
The last three are transitive and needed only a lockfile re-resolve, so no
overrides were introduced.
The @fastify/static major changes setHeaders' first argument from a Node
ServerResponse to a FastifyReply. Two consequences:
1. res.setHeader() -> reply.header(). The v9 body throws TypeError from
inside the plugin on every static request.
2. Precedence flips, silently. The callback used to write to the raw
response and lose to the route's staged reply headers; it now writes to
the reply and wins. That gave /sw.js a year of immutable in place of the
no-cache, no-store its route sets, pinning a service worker on every
client with no server-side recovery. A route that already set
Cache-Control now keeps it.
Verified against v9 to confirm the sw.js behaviour is a regression and not
a pre-existing bug.
ws appears in npm audit but production is on 8.21.0, outside the vulnerable
range; the only affected copy is bundled under @remotion/renderer (dev-only,
and remotion is pinned at 4.0.473 because the compositor refuses to start on
a version mismatch).
Adds test/static-cache-headers.test.ts, which drives a real server and covers
a caching contract that had no test at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm test` ran config/vitest.config.ts, which includes the browser, visual and
perf suites. On any machine without chromium, a free port and per-machine PNG
baselines that fails ~87 tests on a clean master, so the repo's most obvious
command could not be used as a pass/fail signal. The workaround had spread into
four docs as "never run bare `npm test`" warnings.
`npm test` now runs config/vitest.ci.config.ts — byte-for-byte what CI runs — so
local green means CI green. Verified: 264 files, 5248 tests, exit 0.
The suites it leaves out are not abandoned; each has a command:
test:browser 5 Playwright files (chromium + a live server; codex-predictive-echo
also needs a real codex binary)
test:mobile unchanged — the above plus per-machine PNG baselines
test:perf 2 wall-clock benchmarks; need an otherwise idle machine
test:all the old everything-behaviour, kept reachable
test:ci is untouched (CI still calls it). test:watch and test:coverage follow
test onto the gate's config.
The more important half is the hole this closes. The exclusion list lived as
literals in one config and pointed one way only: a file excluded from CI and
added to no runner would be tested by NOTHING, silently, with every command
still green — vitest counts "no files matched a filter" as success. That is the
same shape as the #279/#280 blind spot already documented in CLAUDE.md.
So the globs moved to config/test-suites.ts, one array per REASON a suite cannot
run in CI, and all three configs derive from it. test/test-suite-partition.test.ts
then checks the arithmetic against the files on disk: it fails if any test file
is reachable by no runner, or by two. Confirmed it fires by orphaning a file and
watching it name it. The partition is exact today:
gate 264 + browser 5 + perf 2 + mobile 9 = 280 = every *.test.ts in the repo
⚠️ One sharp edge, deliberate and documented: a file filter must match its
runner. `npm test -- test/mobile/keyboard.test.ts` now matches nothing and exits
GREEN having run zero tests, because the gate's config excludes that path.
CLAUDE.md recommended exactly that command in the on-screen-keyboard note; that
line now says `npm run test:mobile -- <file>`, and the Testing section calls out
the trap, since a green run of zero tests is worse than a red one.
Docs synced: CLAUDE.md, AGENTS.md, .github/CONTRIBUTING.md, README.md,
README.zh-CN.md, and two ci.yml comments that claimed only test/mobile/** was
excluded — it is three suites, and 5 Playwright files rather than 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Session List Layout gains a third option. The old "Left sidebar" becomes
"Left sidebar simple" and is unchanged down to the byte; the new "Left sidebar"
puts on each row what the desktop home rail and the phone overview already show:
when the session was first created, how long it has been in the state it is in,
and a status pill naming that state.
A docked column is not a tab strip. It has width to spare and a row per session
either way, and "name + folder" is the whole story a TAB can tell, not the whole
story there is. This is the information that was missing, and it already existed
one surface over.
Both sidebar values are the same layout, and both set data-session-list="sidebar";
the row detail rides on a separate data-sidebar-detail attribute. That split is
the load-bearing decision here: every one of the ~25 isSessionSidebarActive()
call sites and every html[data-session-list="sidebar"] rule in styles.css and
mobile.css keeps matching both variants without being touched. A third
data-session-list value would have meant auditing and editing all of them.
- Stored values: 'header', 'sidebar' (simple), 'sidebar-rich'. Anyone already on
'sidebar' keeps exactly the layout they picked — the rename is label-only.
- State classification and the "how long has it been like this" anchor come from
_mobileOverviewState() / _mobileOverviewSince(), not re-derived, so the three
surfaces cannot disagree about what "working" means. A working pane repaints
~1/s, so its duration is measured from the turn's last Enter: a running turn
reads "working 12m", not "0m".
- Stamps refresh in place on a 20s clock rather than by re-rendering — a rebuild
would restart every load spinner and alert animation in the list, twice a
minute. The clock runs only while rich rows are on screen, and is stopped from
both render paths and from applySessionListLayout().
- The incremental render path updates the pill, the accent class and the since
anchor; a tick alone cannot see a state change, and a new turn re-stamps
lastSubmitAt without changing state.
- applySessionListLayout() now re-renders on a DETAIL change too. simple <-> rich
leaves data-session-list on 'sidebar' both times, and the meta line is emitted
by the row template rather than toggled by CSS, so the old layout-only test
would have flipped the setting and repainted nothing.
- Width: 300px for the extra line. The collapsed 44px rail and the handheld
drawer are both explicitly held back from it — the desktop rule is (0,3,1) and
would otherwise out-specify mobile.css's (0,2,1) drawer base and pin a 320px
phone's drawer to 300px.
- Missing/stale mobile-overview.js degrades to a row with no meta line rather
than throwing and taking the whole tab strip down.
15 new tests cover the attribute split, the solo-window override, the
detail-change re-render, the row model, both render paths, the clock lifecycle
and the mobile width guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>