fix(remote): never auto-revive a remote session after a clean agent exit - #1
Closed
timkjr wants to merge 439 commits into
Closed
fix(remote): never auto-revive a remote session after a clean agent exit#1timkjr wants to merge 439 commits into
timkjr wants to merge 439 commits into
Conversation
Regression from the dismiss handler in Ark0N#279: it fired on any touchend, and a scroll ends in touchend too. Scrolling to read something while composing closed the keyboard and dropped the composer — worse than the bug it fixed. Track finger travel from touchstart and only treat a near-stationary gesture as a tap, using the same 8px TAP_THRESHOLD the terminal's own touch handling uses so both agree on tap-vs-scroll. Multi-touch is never a dismissing tap. All three listeners stay passive; nothing calls preventDefault. Measured on a Pixel-class viewport with a Firefox UA: tap -> dismissed scroll (120px) -> keyboard kept micro-drift (4px) -> dismissed, so an imprecise tap still works Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dismiss handler fired on any touchend, so a scroll closed the keyboard too — a regression the original test could not see, because it only ever dispatched a stationary tap. The helper now takes an optional travel distance and emits touchmove steps, and the test asserts a 120px scroll leaves the terminal input focused. Removing the `if (moved) return` guard fails this assertion, so it genuinely pins the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every terminal tap re-focuses the hidden textarea, so once the on-screen keyboard
is open the only way to close it is the accessory bar's dismiss chevron. Tapping
the transcript to get the screen back is the obvious gesture and it did nothing.
A tap on INERT content with the keyboard already up now dismisses it. Nothing
else claims that gesture: an inert row has no action to trigger, so by that point
the tap has already done its only other job (the mouse report).
Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
focus-then-position, so a second tap there still places the caret — that is real
capability and trading it away would be a worse deal than the bug. A separate
test pins it rather than leaving it to the reader.
Actionable rows are unchanged: readbacks, "esc to interrupt" status rows and menu
selections still blur via _isActionableMobileTerminalTap, which runs first.
`keeps the hidden keyboard input focused after an inert Claude transcript tap`
asserted the OLD behaviour and is renamed and inverted, since revising that
behaviour is the point of this change. Its setup already focused the terminal
before tapping, so it was always exercising the second-tap case.
test/terminal-touch-tap.test.ts: 28 tests. The two new ones fail on master —
`closes the keyboard on a second tap of INERT transcript content` behaviourally,
by asserting blur where master re-focuses.
test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same five
pre-existing failures as master, untouched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>…k0N#280) Ark0N#279 and Ark0N#280 auto-merge cleanly, but the merged result was red: neither branch could see the other, and CI cannot see either, because the only test covering Ark0N#279 lives in test/mobile/** which test:ci excludes. Two problems, both in Ark0N#279's test: 1. The in-terminal case tapped the terminal's top-left corner, i.e. an inert transcript row, and asserted focus was retained. That is precisely the gesture Ark0N#280 redefines, so Ark0N#280 turned it red. Aim it at the PROMPT row instead: the one in-terminal tap whose outcome neither PR claims, so it still proves the #terminalContainer exemption without asserting the toggle's behaviour. 2. The "a real control is exempt" case was VACUOUS. It picked the first button measuring >8px, which is .welcome-ralph-link inside the welcome overlay hideWelcome() had already hidden: the rect still measures, but elementFromPoint at that point returns .xterm-screen, so the case tapped the TERMINAL and passed for the wrong reason. It only surfaced because Ark0N#280 changed what a terminal tap does. Require the sampled point to actually resolve to the button, and fail loudly when no control is usable rather than silently asserting nothing. Mutation-checked: removing the install, the #terminalContainer exemption, the control exemption or the `if (moved) return` scroll guard each turns the test red on its own. The control exemption had no coverage before. Also fold the duplicated tap slop into one constant: initTerminal's TAP_THRESHOLD now reads MOBILE_KEYBOARD_DISMISS_TAP_SLOP instead of re-declaring 8, since a drift between them is exactly the bug the second Ark0N#279 commit fixed. And restore the comment the slop constant was inserted into the middle of, which left "Regions where a tap must NOT dismiss" sitting above the slop rather than the selector it documents. test/mobile/keyboard.test.ts: 5 failed | 47 passed (52). Master is 5 failed | 46 passed (51) — the same five pre-existing failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… header Below 860px Save moves into the header (a bottom action bar would cost 60px of a phone sheet), which left the two ways OUT of the sheet sitting side by side in mismatched shapes: a fat accent pill next to a bare 1.5rem glyph with no box at all. They are the same decision (save-and-close vs discard-and-close), hit in the same corner with the same thumb, so they now share a recessed tray and matching pill geometry and read as one cluster. - 36px on both, so the tray comes out at 44px including its 3px padding and 1px border — the same height as the phone header it sits in. - `.modal-close` gets a real box (36x36, radius 9) only inside the tray; its bare-glyph form is still right in a plain modal header. - Tray colors come from skin tokens (--border/--bg-input). A hardcoded black alpha would render as a grey slab on the four light skins, the same trap the layout preview frame hit. - `:has(.set-head-save)` keeps the tray off the sheets that carry a lone x: Session Options and Add Case save from inside their own forms. - The shared focus ring offsets OUTWARD, which inside the tray would draw on top of the tray border, so it is inset to ring the button instead. DOM order stays close-then-save so the focus trap still lands on Close; row-reverse paints Save to its left. Verified at 390x844: tray 44px tall, Save 36px, Close 36x36, both radius 9 inside a 12-radius tray. PostCSS-parsed (prettier does not catch an unclosed CSS block, and styles.css is prettier-ignored by design). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Release 1.16.6: phone overview started/idle stamps, plus fixes for the selection-dialog keyboard lockout, the accessory bar arrows bypassing the local-echo overlay, and recovered sessions being restamped as newly created on every server restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SessionMode gains 'pi', a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron agentType, Docker and remote-SSH command defaults, and clone-repo Brain option. Pi is a different shape of CLI from the other four, and three decisions follow from that: - It has NO permission prompts and no sandbox, so there is no --dangerously-skip-permissions analog and none was invented. The privilege-shaped knob is the tri-state approveProjectTrust, which makes pi load and EXECUTE repo-local .pi/extensions TypeScript and install missing project packages. clampExternalCliBypassForOwner() therefore puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets --no-approve even when no config was sent, because pi's own default is a prompt the session user could answer themselves. That helper had zero test coverage; it now has coverage for all four CLIs. - Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Auth goes through pi's /login or the server's own environment. --api-key is deliberately never wired: it would put a provider secret on the spawn command line. - pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the main screen with terminal-owned scrollback, and its 0.84.0 fullscreen mode is runtime-switchable via /settings; that flip was measured to put the pane into the alt screen, which the strip would have corrupted. pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name a stray binary can shadow; GET /api/pi/status surfaces path and version so a misresolution is diagnosable rather than presenting as a broken mode. Docker installs pi in its own --ignore-scripts step so that flag cannot affect the other four CLIs, and seeds its credentials per-file rather than whole-dir (~/.pi/agent also holds sessions, extensions and package trees). Verified end to end against pi 0.84.1 on an isolated instance: resolver search-dir fallback, flag construction, piConfig persistence across a full server restart, the trust prompt and its --no-approve suppression, the rose Run button on the default daylight-blue skin (the nested skin block eats per-mode gradients unless the rule lives inside it), and the buffer local-echo policy, which pi tolerates where codex did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting on SSE Renaming a tab appeared to do nothing: the new name only showed after a full page reload. The PUT always succeeded; what was broken is how the tab strip learns the result. `finishRename()` re-renders the strip from the client-side `app.sessions` map, and nothing wrote the new name into that map, so the rename depended on the `session:updated` SSE frame to carry its own write back. On a page whose stream has gone quiet without erroring, that frame never lands and the re-render repaints the stale label. - `_applyLocalSessionName()` writes the confirmed name into `this.sessions` and refreshes cached subagent parent names, mirroring `_onSessionUpdated`. - `_putSessionName()` returns the stored name or null. `_apiPut` turns a network error into a null Response and an API failure into a non-ok status, so a rejected rename previously read as success and silently dropped the edit (the old try/catch could never fire). - Both surfaces use them: `startInlineRename()`'s `finishRename` and `saveSessionName()`. Two regression tests: the commit applies the name with no SSE frame dispatched, and a 500 restores the old label, leaves the map untouched, and toasts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups on Ark0N#282. All four are the same failure shape: a list that enumerates run modes, missed by the sweep that added 'pi'. 1. Cron ignored pi's project-trust clamp. The PR widened CronJobBaseSchema's agentType to accept 'pi' but not the matching clamp beside gemini's, so a non-granted multi-user owner's cron pi job spawned bare `pi` (pi's own defaultProjectTrust, an interactive prompt they can answer "yes" to, which loads and EXECUTES repo-local .pi/extensions TypeScript) while the same user's UI/API launch was forced to --no-approve. The clamp is now a pure exported helper, clampCronExternalCliConfigs(), so both it and gemini's previously untested materialization are pinned. 2. POST /api/sessions/:id/interactive auto-enabled the Ralph tracker for pi: its denylist covered opencode/codex/gemini/antigravity only. The tracker is never fed for an external CLI (_processExpensiveParsers returns early), so a pi session reported ralphEnabled and Ralph UI state no sibling backend shows. 3. REMOTE_CLI_BIN had no pi entry, so buildRemoteCliVersionProbeCommand() returned null and Session.cliVersion stayed blank for every remote-SSH pi session, even though the PR wired the remote launch command and the per-mode override schema field. 4. The desktop home rail's badge map had no pi entry, and its lookup falls back to '', which is what claude renders. A pi session read as Claude there while the tab strip and phone overview badged it correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An EventSource that stops delivering does not always error. A proxy that
idle-closed the connection, a laptop resumed from sleep, a tailnet reconnect:
`onerror` never fires, the header dot stays green, and every SSE-driven surface
(tab status dots, sessions created on another device, renames) freezes until the
user reloads. Nothing on the client tracked stream liveness at all.
The server already wrote a keepalive every 15s, but as an SSE `:keepalive`
COMMENT, and comments are invisible to `EventSource` by spec, so there was
nothing a client could observe.
Server:
- `sse:heartbeat` under a new Transport category in the event registry
(155 constants now, both counts updated).
- `cleanupDeadClients()` writes that named frame (`{"t":<epoch ms>}`) instead of
the comment. Interval, tunnel padding and dead-socket eviction are unchanged.
The write stays per-client rather than going through `broadcast()`: the frame
carries no session data, so it needs no multi-user owner routing.
Client:
- `computeSseStale()` in constants.js, a pure policy beside
`computeConnectionLossUi`. Stale only when the transport believes it is
`connected`, the device is online, and no frame has arrived for 45s (three
missed heartbeats). The `connected`-only guard is also the loop breaker: a
forced reconnect leaves that state immediately, so the watchdog cannot re-fire
while one is in flight.
- The liveness stamp is applied inside `addListener` itself, so the
`_SSE_HANDLER_MAP` wrappers and the directly-registered listeners all feed it
from one place instead of three that can drift. The heartbeat's own listener
is a no-op that exists only to be registered, since `EventSource` drops named
events nobody listens for.
- A 5s watchdog forces `connectSSE()` when the policy says stale, and is cleared
at the top of `connectSSE()` and nowhere else (its only teardown path).
Recovery needs no new sync path: the reconnect re-runs `handleInit`, which
already rebuilds from the server. `visibilitychange` -> visible checks too,
riding the existing listener, since a background tab's timers are throttled
and a wake is exactly when a stream comes back zombie.
- The forced reconnect logs one diagnostic line: if a middlebox ever strips or
delays heartbeats, the failure mode is "silently reconnects every 45s", which
is undebuggable from a field report without it.
Tests: `test/sse-staleness.test.ts` (node VM over constants.js, threshold
boundaries and every not-stale guard) and `test/sse-heartbeat.test.ts` (drives
`cleanupDeadClients()` with fake replies: named frame not a comment, parseable
payload, padding only with a tunnel, dead clients still evicted).
Verified end to end on an isolated instance: with the stream closed client-side
(no `onerror`), a rename sticks, an out-of-band session stays invisible, then
the watchdog reconnects on its own and it appears without a reload.
Event names are part of the stable API contract, so this is a MINOR bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>…ionale, update the skill Second review pass on Ark0N#282, the three items left open after f4dcfbe. 1. `codeman doctor` and the run mode disagreed about pi. The registry entry accepted a bare `which pi` hit while pi-cli-resolver demanded semver-shaped `--version` output, so the Dependencies panel could report an installed Pi CLI on a box where Run Pi stays hidden, which reads as a broken mode rather than a missing install. Both sides now share one exported PI_VERSION_REGEX, and PathResolver gains an opt-in `requireVersionMatch` so a binary that fails the shape check is reported MISSING instead of installed-with-unknown-version. Only pi sets it; every other tool keeps its current behaviour. 2. The isAltScreenStripMode comment justified excluding pi with "the alt screen is load-bearing for its fullscreen TUI". That is not what exclusion does: pi is tmux-backed, so it falls through to isMuxAltScreenOnlyStripMode, which strips the alt-screen toggles anyway. What exclusion actually preserves is `\x1b[3J` and the mouse DECSETs, which is the real reason (pi renders into the main screen and is mouse-aware). Comment and changeset now say that, and state the consequence: fullscreen pi paints into the main buffer, like vim in a tmux shell session. 3. skills/codeman still enumerated the five pre-pi modes in nine places, telling agents a backend does not exist and understating class-wide caveats by one mode. All updated, plus stale session.ts line references refreshed. Tests: a new static guard derives the mode set from the Zod schema (not a copy) and fails when a skill enumeration lists a partial set of external CLIs, verified by mutation. It also documents the one legitimate exception it found: the "writes no transcript" lists drop codex, which does write a rollout Codeman reads back. Plus doctor cases for an unrelated `pi` on PATH and registry/resolver regex parity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…family `GET /api/pi/status` shipped undocumented in the agent skill, and only a human reading the doc noticed. Turns out none of its five siblings were documented either, so this adds the whole family in one place: spawning with a mode whose CLI is absent fails with OPERATION_FAILED rather than falling back, which is exactly what an agent picking a backend it did not choose needs to know. Pi's extra `.data.version` is called out, since a false `available:false` there means an unrelated `pi` is in front on PATH. On whether the endpoint scanner should also check registered-to-documented: measured, and NO for the general case. The skill documents 34 of 217 registered endpoints deliberately (it is an agent guide, not an API reference), so a blanket reverse check needs a 183-entry allowlist that would fail CI on unrelated route work and get appended to mechanically, which is worse than the gap it closes. Grouping by path shape does not save it either: the families that yields are things like `DELETE /api/<any>/:id`, lumping cases, webviews and docker hosts together, and it would not have caught this gap anyway (the family had zero documented members). What IS cheap is a family the schema can enumerate with no allowlist: the new assertion derives the agent modes from the Zod enum and requires each one's `/api/<mode>/status` to be documented, so a seventh backend fails here until it is. The sibling scanner still proves the other direction, that nothing documented is a 404. Both mutation-checked: dropping pi's probe fails the new guard, and documenting a nonexistent probe fails the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(pi): add Pi (pi.dev) as a sixth CLI run mode (Ark0N#206)
Heal a stalled SSE stream: the server's :keepalive comment becomes a named sse:heartbeat event (comments are invisible to EventSource by spec), and the client gains a staleness watchdog that forces a reconnect after three missed beats. Also applies a confirmed rename locally instead of waiting on SSE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR Ark0N#282 added pi across the prominent surfaces but left the enumerations that read as exhaustive: the env-prefix allowlist (missing PI_*), the external-CLI list for stop/blocked, cron's agent types (also missing antigravity), the narrow-strip mode list, and the claude-only caveats in the cron and Read My Mind guides. Both READMEs and the four affected docs now agree with the schema. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… history truncation ClosesArk0N#259, closesArk0N#258. Both bottom out in the same gap: nothing tracked whether the user was following live output or reading history. Ark0N#259 — the keyboard path forced the terminal to the bottom unconditionally (onKeyboardShow/onKeyboardHide passed scrollToBottom:true, applied with no check), so opening the keyboard while scrolled up yanked the user down. The settle cycle now captures intent on its FIRST event, before any fit() has reflowed the buffer, and returns to that anchor when the user was reading. A later capture would read an already-moved viewportY, which is why the capture point matters. The param is renamed restoreScroll to match. Separately, flushPendingWrites gated viewport preservation on _hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and then actually READ for longer lost protection mid-read. Being scrolled up IS the intent however long ago it was expressed, so it now keys off position. The recency window stays as a race guard on the sticky scroll-to-bottom. The full-history repull already held the user's place and is unchanged. Ark0N#258 — truncation was reported by a grey line written INTO the terminal ("earlier output truncated"), which scrolls away with the output it describes, cannot be acted on, and said the same thing whether the rest was one click away or gone forever. The server set one `truncated` boolean at two sites meaning opposite things, and the client discarded fullSize and source entirely. The route now reports truncationReason ('tail' = intentional partial replay, the rest is retained; 'capped' = the byte ceiling dropped it) plus retainedBytes, and 'capped' is not downgraded by a later tail cut. The client renders a dismissible banner outside terminal output with three honest states: recoverable (offers Load full history), at-ceiling, and exhausted. The Load button forces past the scroll cooldown but NOT past _replayWouldShrinkBuffer, which still refuses a downgrade for repaint-mode panes. The banner is an overlay, not a flex child: FitAddon derives rows/cols from the terminal parent's computed height, so occupying real layout space would SIGWINCH the CLI on every truncation-state change. Verified in a real browser on the 7 skins: banner text and button clear 4.5:1 contrast on all of them, and terminal height is byte-identical with the banner shown. The first cut used --bg-elevated and --accent-muted, which do not exist, so light skins rendered a hardcoded dark bar under dark text; it now uses only tokens every skin redefines. test/terminal-scroll-intent.test.ts lives outside test/mobile/ deliberately — that suite is excluded from test:ci, so a guard placed there is invisible to CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four documentation defects found while analysing the agent skill against the
code it drives.
The lineage section attributed "deletes its session as soon as the one-shot
prompt returns" to `POST /api/v1/sessions/:id/run`. That is true of
`POST /api/v1/run`, which creates a throwaway session and calls cleanupSession
on both the success and the error path; the per-session route deletes nothing.
Name the right endpoint, and give the real reason the per-session one carries
no lineage: it is not a create call.
While verifying that, the per-session route turned out to be a sharper trap
than documented. `runPrompt()` rejects whenever a PTY already exists, which is
every interactive session, but the route has already returned `{}` with HTTP
200 by then and routes the rejection only to SSE. An agent calling it against
a live worker reads the 200 as delivery. Document it.
`Flow 3b` never existed in recipes.md. The real mapping is Flow 3 = shell
fan-out, Flow 4 = claude fan-out, Flow 5 = worker blocked on a prompt, so the
same sentence was also mislabelling Flow 4. Fixed in SKILL.md and in the
endpoints.md reference to it; every other Flow reference audited and correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Two bugs in the File Viewer's media player, both reproduced in a real browser against an 18MB mp4 before and after the fix. 1. Closing the preview left the video playing. closeFilePreview() only dropped the overlay's `visible` class, which is display:none and nothing else, so the audio kept going with no visible player to pause. Detaching the element is not a fix either: a detached HTMLMediaElement plays on until it is garbage collected. _stopFilePreviewMedia() now pauses, drops src and load()s every media element (also on re-open, where overwriting innerHTML had the same effect), which additionally aborts the in-flight download. 2. The scrub bar was inert. file-raw read the whole file and answered 200 with no Accept-Ranges, so Chrome reported video.seekable as [0, 0] and silently reverted `currentTime = x`; Safari refuses to start such media at all. Raw bodies are now streamed and range-aware: Accept-Ranges: bytes on every response, 206 + Content-Range for a Range request, 416 for one past EOF, and a malformed spec ignored (200) per RFC 9110. Parsing is pure in src/web/http-range.ts. Measured on tmp/codeman-crt-v5-66s.mp4 (18MB, 66.6s): before seekable [0, 0] seek to 56.6s reverted to 3.9s close: still playing after seekable [0, 66.56] seek to 56.6s landed at 60.2s close: paused, NETWORK_EMPTY Range slices are byte-identical to `dd`, the full-file path is byte-identical to the file, and the SVG octet-stream/attachment hardening and the 50MB cap are unchanged (the cap is still checked before the range). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lines that join a tab to the workers its codeman skill spawned were drawn with numbers tuned against two tabs sitting side by side, and they degraded in exactly the two situations the feature is actually used in. 1. A spawned worker is appended to the END of the strip, so the real span between a lead and its worker is 800-1500px. With the dip clamped at 44px that is a 33px sag: the arc reads as a straight line drawn across the terminal instead of a bracket hanging under the strip. The dip now grows at 0.085/px and clamps at 104. 2. When the desktop strip wraps (tabs-two-rows / tabs-auto-wrap), a parent on row 1 and its child on row 2 are ~14px apart, and the cross-row branch drew parent-bottom to child-TOP: a flat line hidden inside the row gap, with siblings overprinting each other. Both ends now anchor on the tab BOTTOM with the control points below the LOWER row, so a wrapped pair gets the same bracket a flat strip gets. That deletes the branch: one shape covers both. Visibility, at 1:1 rather than in a zoomed mockup: 2 -> 2.5px stroke, 4 4 -> 5 5 dashes (lineage-flow moves with them, -16 -> -20), opacity .55 -> .72, and a second wider glow so the contrast comes from the halo rather than from more weight, keeping the line under the subagent lines' 3px. A working child is bright (.95) outside the reduced-motion block, so turning motion off no longer also dims every worker's arc. Sibling nesting 6 -> 8px and the direction dot 3 -> 3.5px to match the heavier stroke. Verified at 1:1 in a harness driving the real styles.css and the real computeLineagePath over three layouts (adjacent workers, workers at the far end of a full strip, wrapped two-row strip) on a dark and a light skin. test/session-lineage-lines.test.ts pins both regressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e buffer Two further instances of the same root cause, both in _onSessionNeedsRefresh, which is SERVER-triggered (it fires after SSE backpressure clears) so the user has no gesture to blame the result on. 1. It ended in an unconditional scrollToBottom, so a user quietly reading scrollback was dropped to the live output by a background event. It now holds their place. The rewrite REPLACES the buffer, so an absolute viewportY captured beforehand is meaningless afterwards; distance from the bottom is the anchor that survives, via computeRewriteScrollLine(). 2. It rebuilt the terminal from a 1MB TAIL. Measured end to end on a 900-line shell pane: an 869-row buffer came back as 158 rows, so the refresh meant to REPAIR the display was destroying most of the scrollback every time it ran. It now asks for full history, and falls back to the tail only when _replayWouldShrinkBuffer refuses the capture, which keeps repaint-mode panes (tmux holds roughly one frame for them) exactly as they were. Also records truncation state here, so the Ark0N#258 banner stops describing the pre-refresh buffer. Verified in a real browser against a live session: baseY 869 -> 869 where it used to be 869 -> 158, a reader 200 lines up stays 200 lines up, and a follower stays pinned to the bottom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refresh can now issue two fetches (full history, then the tail as a downgrade fallback), which widens an existing window where the user switches tabs mid-flight and this session's history gets painted into the terminal they are now looking at. Guard it the way _maybeRefetchFullHistory already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…history Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(file-viewer): make previewed video seekable and stop it on close
fix(ui): make session lineage lines read as arcs, not straight threads
…l, make the hook gate per-session 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.
…llback profile classifier 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.
…ot skip the first render 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.
…and trust its frame
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.…l tab 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).
`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.
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.
… compact wrap pass, rich-aware resets 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>
… gate, poll memo, honest pairing docs 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>
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>
…/deepseek-agent-workers # Conflicts: # CLAUDE.md
feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Spawn and drive DeepSeek Harness workers from the codeman agent skill
Vertical tab rail: detailed rows (created / working / status), plus a rename-cancel fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kpressure fix(terminal): bound live xterm backpressure
feat(file-viewer): COD-341 search the full workspace
install.sh installs a build toolchain on Linux (node-pty has no Linux prebuild, so a stock Ubuntu 24 server died inside node-gyp with "not found: make"), plus review hardening for Ark0N#339: the write-queue reset paths now release the one-chunk-in-flight gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a failed build The network-access prompt, where Tailscale serve is configured, runs AFTER the build step. A build failure therefore exits before the question is ever asked, and a user who then finishes the build by hand (rather than re-running install.sh) ends up with a healthy loopback-only Codeman, a connected Tailscale, and no serve mapping — with nothing anywhere pointing at `install.sh tailscale`, the command that fixes it. Reported from a fresh Ubuntu 24 install that died on the node-pty compile. - maybe_offer_tailscale_repair(): on the update/re-run path, detect exactly that state (loopback bind + tailscale Running + no serve mapping fronting Codeman) and offer the retrofit. Silent for a deliberate non-loopback bind, silent once a mapping exists, silent when tailscale is absent, and prints the command instead of prompting when non-interactive. Returns 0 even when setup fails so it can never abort an update. - print_security_notice(): the loopback branch now names `install.sh tailscale` when Tailscale is installed on the box, rather than the generic "tailscale serve / cloudflared tunnel" advice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The installer bullet still described a two-way choice with 0.0.0.0 as "the default", which predates the Tailscale option. The prompt has offered three choices for a while (Tailscale / any device on your network / this machine only), and the default is computed from what is already on the machine rather than being fixed at 0.0.0.0. Now states all three options, that the Tailscale one is a loopback bind fronted by `tailscale serve` with the tailnet as the login, and how the highlighted default is chosen. Line 220 already documented the Tailscale option correctly; this was the only stale spot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(ui): show inline rename text in session sidebar
…imits feat(web): show Codex plan usage in header
The COD-108 reconnect watcher treated any dead local pane as a dropped transport and re-ran the pane command — so a normal ctrl-c/ctrl-d on a remote claude/opencode/omp auto-spawned a FRESH agent (claude only looked correct because its '--session-id || --resume' fallback resumed, with a loud 'already in use' error first). Distinguish a transport drop from an intentional exit: only reconnect when the durable remote tmux session (codeman-ssh-*) is verifiably still alive on the remote host. A clean exit tears that session down; the watcher now probes it via ssh has-session and skips (remote-gone) when it is gone OR unknown (fail closed). The probe is cached per-session and fired async so the 5s tick never blocks on ssh. Tests: 3 new cases pinning remote-gone / unknown / alive decisions. Verified live: all remote CLIs stay dead after ctrl-c/ctrl-d.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A normal
ctrl-c/ctrl-d/exitinside a remote CLI session (Claude, OpenCode, OMP, …) auto-respawns a fresh agent. The COD-108 remote-reconnect watcher polls every 5s and, for any remote session whose local pane is dead, emitsremoteSessionDropped— which reatttaches by re-running the pane command.The watcher intended to recover from a transport drop (ssh drops, agent still running on the remote). But
isPaneDead()is true in BOTH cases: a transport drop AND a normal agent exit. A clean exit tears down the durable remote tmux session (codeman-ssh-*,remain-on-exit faileddestroys the session when its only pane exits), so the watcher cannot tell the two apart — and re-runs the command, launching a brand-new conversation.Claude only looked okay: its remote launch is
claude --session-id <id> || claude --resume <id>, so the fresh run resumed — but not before printing a loud "already in use" error. OpenCode / OMP started fresh every time.Fix
Only auto-reconnect when the durable remote tmux session is verifiably still alive on the remote host:
remoteTmuxSessionAlive()runstmux -L codeman-remote has-session -t codeman-ssh-<id>over ssh (exit 0 = alive).decideReconnect()gains aremote-goneskip: when the remote session is gone (clean exit → do not revive) OR the probe is unknown (unreachable host → fail closed, do not revive), the watcher does nothing.falseand the auto-revive stops.Transport-drop behavior is unchanged: remote tmux alive → reconnect as before.
Testing
remote-auto-reconnect.test.tspinning the decision: remote alive → emit; remote gone → skip; remote unknown → skip.ctrl-c/ctrl-don remote Claude, OpenCode and OMP sessions, the pane stays dead (no auto-respawn).This is intentionally independent of any CLI-specific resume logic — it fixes the watcher for every remote mode at once.
Note: I have a separate open PR (Ark0N#353) for OMP backend support. This fix is deliberately scoped away from that branch; it applies to upstream
masterstandalone.