The phone keyboard's Path key (insertTerminalText) and its clear-prompt key
(clearTerminalInput) always wrote to the main pane: into its local-echo
overlay, or to the active session. With the tile grid open that overlay is
parked behind the grid, so a picked path landed there unseen and only reached
the session later, and with the split open a path meant for Pane B went to
Pane A.
Both now ask _focusedPane() first. When a tile or Pane B holds the keyboard,
the path is sent to that pane's session through the exactly-once queue, and
clearing sends Ctrl+U there (those panes have no overlay, so the TUI owns the
line), then the pane's own terminal takes focus. The main pane's behaviour is
unchanged. Left over from the final checkup's dictation fix (c10), which
covered voice input only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redraw (Ctrl+Shift+R and the header button) shows five literal toasts and a
size report on the main pane, a tile or the split's Pane B. None had a zh-CN
entry, including the two the final checkup's tile Redraw fix added, and
"Failed to restore terminal size" fell to the generic "Failed to" pattern,
which left English behind. They now translate, and the size report keeps its
numbers through a pattern rule. test/redraw-toast-i18n.test.ts reads the
toasts from restoreTerminalSize() itself, so a reworded one without an entry
fails.
Comments and docs that still described an older default:
- styles.css: the Tiles header button is no longer opt-in; it is on by default
on desktop and off on phones and coarse-pointer tablets.
- terminal-ui.js: the desktop branch of getDefaultSettings is no longer always
{}; what the comment needs is that it sets no copyStripMargin.
- docs/tile-grid-plan.md: the Tiles default bullet names the tablet default.
- docs/cli-registry.md: codex's footer is read in a two-row window since
codex 0.162's hint row, not from its last row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From the read-only final review of the release, adversarially verified, then reviewed again:
- tiles: a file dropped on the grid uploads to that tile's session instead of navigating away; app-driven tile changes no longer move the keyboard into another session; popping out the last tile no longer leaves a frozen view; "Open group as tiles" no longer merges an open split; the Tiles button defaults off on touch tablets (opt-in)
- voice: dictation with the grid open reaches the focused tile
- css: By case stays one scrolling strip on 600-767px tablets, the needs-you pulse animates opacity only, phone welcome chips are 40px
- i18n: zh-CN for the case picker rows, the git status settings, new toasts, tile and spreadsheet texts
- cli registry: codex launch defaults are registry data, not an id branch; the codex footer reads an ultra effort
- build and docs: a dependency preflight runs before the build deletes dist; docs no longer name 1.36.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- i18n: the spreadsheet notice's feature words (charts, drawings, macros,
pivot tables, external links) were bare, case-insensitive zh-CN keys, so
the page translator also renamed a charts/ or macros/ folder in the Files
panel and a case of that name in the case picker. They are now scoped
'Spreadsheet feature: <word>' keys; warningText() falls back to the plain
word when the scoped key has no translation (English, no i18n). Removing
the bare keys closes this branch's regression. The older 'models' key has
the same class of problem; it is left alone here, since adding
.case-combobox-option-label to USER_TEXT_SELECTOR would also untranslate
the picker's two action rows and still miss the title attribute.
- Spreadsheet notice bar: marked data-i18n-skip. It is written already
translated, item by item, and ends with a number format's code, which the
observer's t() over the whole line rewrote ({name}, "Codeman").
- Connection tile (Header Stats Style Tiles): applyLocalization() now repaints
the indicator, so a switch back to English no longer leaves the Chinese
value word in its data-i18n-skip span until the next keystroke or ACK.
- Tiles default: loadAppSettingsFromStorage() no longer caches the
posture-dependent showTileGridButton default, so the init merge never
persists it; a 2-in-1 first opened as a tablet gets the button once docked.
Every reader still resolves the absent key through a fresh
getDefaultSettings(), so phones stay OFF and desktops ON.
- Tile grid over a split: closeSplitPane() skips Pane A's closing resize only
when Pane A becomes a tile. With mergeSplit false (Open group as tiles, a
stored grid) a Pane A left out of the set gets its full width back before
the main terminal parks, instead of keeping the split's half width.
- By-case tab strip on tablets and phones: with the boxes dissolved, the
-<case> part of a generated name shows again, so w1-alpha and w1-beta no
longer both read "w1".
Each new assertion fails against the previous source.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From the read-only final review of the release, adversarially verified:
- a server clear frame refreshes a tile instead of blanking it, so a Claude session Run into the grid keeps its banner and transcript
- a tile refresh fetches first and resets in-stream, so the screen never goes blank while it waits
- Redraw on a tile sends the forced resize and reports only what it sent
- each tile caps its live-output backlog (4 MiB) and recovers dropped output with one bounded refresh
- the tile grid plan is marked merged
Gate green on the branch: static checks, 41 tile and split unit files (836 tests) and 7 browser files (36 tests).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- docs/tile-grid-plan.md: the As built bullet on tile loads said a refresh
clears the screen at its turn in the queue. Since the fetch-first refresh it
fetches at its turn, keeps the last frame through the wait and its own round
trip, and resets with the queued in-stream \x1bc only once the capture is in
hand; a failed, aborted or empty fetch writes nothing and resets nothing.
- docs/architecture-invariants.md: the tile grid's One load queue paragraph
gets the same correction, and its list of captures that go through the
TileLoadQueue now names the server {t:'c'} refresh and the dropped-output
recovery refresh.
- test/terminal-tile-input.test.ts: destroy() cancelling a pending recovery is
now pinned on the timer itself (armed before destroy(), null right after it,
read before any timer runs), since the recovery callback's own destroyed
guard made the fetch check pass either way; a second test pins that
destroy() starts the live-output count over, so a write callback xterm still
owed counts nothing. Both fail with the _resetLiveFlow() call removed from
destroy().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- scripts/build.mjs resolves exceljs/dist/exceljs.min.js and fflate first,
before tsc and before rm -rf dist/web/public. A tree whose node_modules
predate those devDependencies (pulled but never ran npm install) used to
fail in prepare-spreadsheet-assets.mjs with the live dist assets already
deleted, so the running server served an index.html whose hashed files
were gone. It now exits 1 with "run `npm install` first", nothing touched.
test/spreadsheet-assets.test.ts pins the order, that the list covers every
require.resolve in the prepare script, and runs a relocated copy of the
build to prove the exit and message.
- CLAUDE.md: the header visibility rule's stock desktop default now lists
Tiles (1180px and wider), which ships ON on desktop.
- docs/wiki/Agent-CLIs.md: "Before 1.36.0" becomes "Before 1.40.0" (four
places); 1.36.0 never ships.
- docs/wiki/Home.md: the "Everything in the manual" index lists Tile Grid
and Custom Model Endpoints, matching the sidebar. test/wiki-home-index
fails when a sidebar page is missing from that index.
- docs/wiki/Tile-Grid.md: the Tiles default is off on tablets too since the
touch-primary default landed, not only on phones.
- docs/browser-testing-guide.md: the fixed port table and new WebServer(PORT)
snippet give way to the port-0 pattern (new WebServer(0, false, true),
server.boundPort) that test/test-ports-guard.test.ts enforces; the
examples that opened localhost:3000, the live instance, use BASE_URL.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Codex footer model detection (c28): the modelDetect.screenLine effort
alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so
'ultra' (offered by the codexReasoningEffort App Setting and codex's own
/model picker) is read and the launch enum and the footer reader cannot
drift again. Still one capture group, 125 characters, no new quantifier.
New session-display-model case loops every effort level, ultra included.
- No CLI-id branching for launch defaults (c27): the two mode === 'codex'
branches the synced codex model/effort defaults added to the create and
quick-start routes are replaced by a registry capability,
capabilities.launchDefaults (launch param -> settings key, values from a
closed enum), declared on the codex entry only. The resolver moved from
web/codex-launch-defaults.ts to web/launch-defaults.ts as
applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's
legacyConfigField object through legacyConfigAliases, still re-validating
with SettingsUpdateSchema and never overwriting a caller's value. The
route exclusions are unchanged (create: not remote; quick-start: not
remote, not Docker, not a custom model endpoint), and quick-start still
derives the session model from a bag without ompConfig, as before.
schema.ts refuses an undeclared param, an unknown settings key, an empty
map, and launchDefaults on an entry with no legacyConfigField.
- The no-id-branching guard now carries an exact occurrence count per
allowlisted key, so a new copy of an already approved expression fails
instead of riding the old approval, with a synthetic anti-vacuity case.
- SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement
default and 'compact' the headerStatsStyle default, matching the
resolvers and the pre-paint script; state/case/ledger are marked opt-in.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The status header of docs/tile-grid-plan.md still said both tile PRs were
"local only" and named private worktrees. Both are merged for the 1.40.0
release (#560, the TerminalTile foundation, and #561, the grid), and the
"As built" section below the header is now called out as authoritative
where it differs from the spec. The Gating section's "default OFF" for
showTileGridButton is marked superseded: the button ships ON on desktop
and OFF on handhelds, as the As built list already says.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The server applies no WebSocket backpressure (16 KB / 8 ms batches, no
bufferedAmount check), and a tile wrote every live frame straight into
xterm. A flood a tile could not parse as fast (a shell tile running cat on
a huge log) piled up in xterm's own write queue without bound, on a main
thread up to six tiles share, until xterm's WriteBuffer threw past 50M
code units; onmessage's empty catch then dropped every frame silently and
nothing recaptured the screen. The primary pane caps its queues and drops
then recaptures (_onSessionTerminal, _scheduleDroppedOutputRecovery).
Each tile now writes live output through _writeLive:
- unparsed code units are counted, each write's callback counting its own
back down; frames held behind a replay (_liveQueue) count too;
- the budget is TerminalTile.LIVE_BACKLOG_BUDGET, 4 MiB, deliberately not
the primary pane's 128 KB: that caps its own rAF-paced queues, while
xterm itself paces a tile, and a tight cap would trip on ordinary bursts
and blank-and-reload the tile over and over;
- past it a frame is dropped, the tile stops writing onto the hole, and one
refresh is scheduled, debounced and bounded by the primary pane's own
rule (CodemanDroppedOutput: 2 s, DROP_RECOVERY_MAX_ATTEMPTS, never retried
after a deadline abort). It is an ordinary refresh, so single-flight,
bounded by lines=/tail= and paced by the grid's TileLoadQueue. The flag
clears once a capture taken after the last dropped frame has replayed;
- a write that throws is the same drop, never a "malformed frame";
- past the bound the flag is released, so a tile is never left frozen;
- a reconnect starts the accounting over (an epoch makes callbacks from
before it count nothing) and drops a pending recovery, since its own
refresh replaces the screen; destroy() cancels it.
The live-queue flush after a pull or a refresh goes through the same path,
so a throwing write there cannot skip the load's marker and trailing
refresh either.
Tests (input harness, real constants and fake timers): the default budget
lets a 1 MiB unparsed burst through, parsed bytes stop counting, a trip
stops writing and ONE debounced refresh recaptures, a write throw takes the
same recovery, a hole in the held queue is recovered by another refresh,
bounded retries then release, no retry after a deadline, a reconnect resets
the count, and destroy cancels. The fake xterm can now hold and release
parses and throw on a write. Live writes now carry a callback, so the unit
tests match them on the data argument (a `.not.toHaveBeenCalledWith(data)`
would otherwise pass for nothing).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Case picker: the "New or link a case…" and "Case settings…" rows that
replaced the translated + and gear buttons get zh-CN entries reusing the
buttons' wording, plus the list's "No cases match".
- App Settings, Bottom bar: the whole group translates as one (heading, the
four Git status rows with #543's max repositories and git timeout, and
their descriptions), so it never reads half English. The Git panel's two
button names, which the new "Git status" key reaches, read naturally too.
- Toasts: the three "Could not open a new window for this ..." errors and
the dictation-closed warning.
- Spreadsheet preview: "Spreadsheet preview failed (<status>)" gets a
pattern. Worker refusals map their error code to one user sentence each in
the renderer (the raw message goes to the console), feature ids read as
words (pivot tables, external links), and the notice bar translates each
item before the join; a number format's code passes through untouched.
The worker and core are unchanged, so their pinned strings and the
asset version hash stay as they were.
- Header Stats Style Tiles: the connection tile's value word reads in
Chinese through scoped "Connection tile: <word>" keys, never bare-word
keys ("retry" is also the orchestrator's Retry button, "LIVE" a resume
list badge). The indicator cache now includes the UI language, so a
language switch repaints it on the next update.
- Tile grid: the "Loading…" label was CSS content text the translator
cannot reach; it is now content: attr(data-loading-label), written through
the translator when the tile is built and each time it starts loading.
Tests pin every new string (zh differs, no English left, English unchanged)
and fail without these changes: the tile harvest sees the loading label and
the rename field, a CSS guard keeps words out of tile generated content, the
Bottom bar group, the case picker rows and toasts read from their source,
the connection tile across a language switch, and the spreadsheet error
codes, notice bar and status line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redraw (Ctrl+Shift+R and the header button, restoreTerminalSize) on a
focused tile or the split's Pane B called tile.fit({ force: true }) and
always toasted "Terminal restored to CxR". In TerminalTile._sendResize,
force only skipped the client-side dedupe: the frame carried no `f`, so
Session.resize skipped a size equal to the one it last applied and the
server did nothing. And _sendResize returned silently with the socket down
or the session popped out to its own window, while the toast still
claimed success.
Both halves are fixed, the first as parity with the primary pane:
- a forced fit now sends `f: true`, the flag the primary's sendResize sets,
which the server honours (ws-routes reads msg.f, Session.resize then runs
tmux resize-window and the PTY resize at the same size);
- fit() and _sendResize() return whether a frame went out, and
restoreTerminalSize toasts success only then. Otherwise it says why, as
the primary branch does: "sized by its own window" for a detached
session, "not connected" while the tile's socket is down (it announces
its size again on reopen), and the primary's "Could not determine
terminal size" for a pane that measured nothing.
What this does not claim: a forced resize to the size the PTY already has
changes no geometry, so it is not a cure for a garbled tile whose PTY
already matches; the primary pane's forced resize has the same limit. It
matters when the server's recorded size has drifted from the tmux window.
Tests: the forced frame carries f:true (and plain ones do not), fit()'s
return value on send, dedupe, detached and closed-socket paths, and Redraw
end to end on a real tile (sent, socket down, popped out), plus the three
no-success toasts in focused-pane-shortcuts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile's refresh (a {t:'r'} or {t:'c'} frame, every reconnect) wiped the
pane with a synchronous xterm clear() at the load's turn, BEFORE its fetch,
and wrote live frames straight through the fetch and the replay. That is
the replay clear CLAUDE.md "Terminal resilience" forbids: bytes still
queued in xterm are parsed after a synchronous clear and fuse into the
snapshot, and clear() keeps the cursor's row, column, SGR and margins, so
the capture (raw rows, no home) started wherever the cursor sat. A failed
or empty fetch left the tile blank.
The refresh now runs in the primary pane's order (_onSessionNeedsRefresh,
_resetTerminalForReplay):
- fetch first, so the tile keeps its last frame through the round trip and
through a grid tile's wait in the load queue;
- from the response on, live frames are held in _liveQueue with their
arrival time, as _pullHistory already did, and the body read of a bounded
window (grid tile, shell) gets the pull's 10 s budget, while Pane B's
unbounded full=1 keeps the request's own budget;
- then the queued in-stream \x1bc immediately before the replay;
- then the held frames that arrived after the response (_flushLiveQueue,
now shared with _pullHistory), then the owed marker.
A failed, aborted or empty fetch writes nothing and resets nothing.
The _stampMarkerIfOwed guard for a pending trailing refresh stays (that
refresh settles the marker itself either way); only its rationale changed.
The fake xterm now treats an in-stream RIS like clear() in its row
emulation.
Tests: the ones that counted clear() calls on the refresh path now count
the in-stream reset instead, assert it sits right before the replay and
that clear() is never called (unit single-flight block, the marker
ordering tests, the reconnect test, the grid {t:'r'} and marker tests, and
the scroll test's server-clear overflow case, which now goes through a
refresh). New: the screen is untouched on a failed or empty fetch and on a
failed body read (held frames written in order), frames before the
response are written through and later ones held behind the replay, the
cutoff drops frames the capture covers, the body budgets, and a grid tile
keeps its last frame through its own capture's round trip.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The server sends {t:'c'} from one place only: a fresh Claude pane's first
prompt (Session.startInteractive), meaning "refresh after startup". The
primary pane answers it with a refetch and replay (_onSessionClearTerminal),
and stands aside while the grid is open, so the tile's own handling was the
only one that ran. That handling was a bare xterm clear(), which keeps only
the cursor's row and drops the banner, a resumed transcript and all
scrollback. An idle Claude never repaints static rows, so a Claude session
Run into the grid, or Attached in a tile, came up as a near-empty tile.
_onLiveClear() now calls _refreshBuffer(), the {t:'r'} path: single-flight,
coalesced into one trailing refresh behind a load already running (a shell
pull's held frames included), and paced by the grid's TileLoadQueue. The
queued {clear:true} entry and its branch in _pullHistory's flush are gone,
along with the _clearTerminal helper they used.
Tests: two unit tests pinned the bare clear (a clear frame queued in order
during a pull, and one applied at once before the capture); they are
replaced by tests that the frame coalesces behind the pull and refetches,
plus a socket-level {t:'c'} test, a coalescing test, and a grid test that
the frame waits its turn in the load queue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Grid tiles and the split view's Pane B now get two fixes the primary pane already had in this release:
- opencode's hollow-buffer wheel paging and its click reports (#555)
- the Android soft-keyboard controller, so autocorrect no longer duplicates a line in a tile (#541, with the #441 drain)
Gate green on the branch tip 7409ad26: 525 files and 10243 tests, plus the touched and adjacent browser files.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tab Layout "By case" at tablet widths (600 to 767px, getDeviceType's
'tablet'): the case boxes could shrink in the tablet's one-row scrolling
strip, so they squeezed and wrapped their tabs inside themselves and every
tab past a box's first line was clipped under the fixed 48px header. The
boxes now dissolve into the chip row as they already do on phones, which
keeps the tablet's own 40px chip geometry. The rule sits in its own
600 to 767px block, not the 768px tablet block, so the desktop path at
768px and up (boxes keep their width, the strip wraps box by box) is
untouched.
Needs-you tile pulse: the glow animated box-shadow on the tile itself, so
the whole tile (DOM-rendered terminal rows included, the whole stage when
zoomed) was repainted every frame for as long as a prompt waited. The tile
keeps its red border; the glow is now a static inset shadow on a
.tile--needs::after overlay (inset because .tile is overflow: hidden and
clips an outer one, z-index 3 above .tile-attach, pointer-events none)
and only its opacity animates. The entering-and-needs animation shorthand
is gone, since it would now blink the whole tile's opacity, and reduced
motion keeps the static ring and hides the overlay.
Welcome chips: the phone block set 36px, below the 40px that styles.css
gives touch screens, and it wins on every phone, so phones got shorter
chips than tablets. It now restates 40px.
Tests: the tablet widths and the 768px boundary in tab-clusters, the
opacity-only pulse overlay in tile-grid-motion, and the phone chip height
in run-mode-ui; each fails against the previous CSS.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the tile grid open the main terminal is parked (display: none), but
local echo stays on, so direct-mode dictation for the focused tile's
session (which is activeSessionId) was appended to the main terminal's
hidden local-echo overlay. Nothing appeared in the tile, Enter in the tile
submitted without the dictated text, and the stranded text was later
flushed into whichever tile had focus when the grid closed, or dropped.
- _insertText: skip the overlay while _tilesOwnTerminal() is true, so the
text goes through _sendToTarget to the session itself.
- The post-insert refocus gives the keyboard to the focused tile (only if
it is still the dictation target) instead of the parked main terminal.
Outside the grid it still focuses the main terminal, so split view keeps
its behaviour even when Pane B took focus mid-dictation.
- Green send button: with tiles open, send only Enter to the target and
leave the parked overlay and main-terminal predictions alone.
The gate is _tilesOwnTerminal(), not _focusedPane().isPrimary: in split
view a target equal to activeSessionId is Pane A with a visible overlay,
and focus read at transcript time could otherwise push Pane A's dictation
past its own unflushed overlay text.
Tests: tile-grid dictation and green-send cases (both fail without the
fix) plus a split-view pin in test/voice-input-target.test.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A file dragged onto the tile grid navigated the browser away: the single
view's drop handler sits on #terminalContainer, hidden while tiles are
open. The #tileGrid section now cancels every file dragover and drop
(bubble phase, so tab and tile drags stay with _acceptTabDrops), and a
drop on a tile uploads its images to THAT tile's session through
_uploadAndInsertImages, with the same "Only image files are supported"
toast as image-input.js (now in the zh-CN table).
- App-driven refocus no longer moves DOM focus into another session's
xterm: a remote delete of the focused tile, _reconcileTileGrid and a
socket closed with 4003/4004/4010 (_onTileExit) pass focus: false.
removeTile gains a focus option; user-initiated removes keep focusing.
- Popping out the last tile left the parked terminal's stale content under
the popped-out tab (and snapshotted it on the next switch).
_selectAfterTileGrid treats a detached session as unusable for both the
focused id and the fallback.
- "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass
mergeSplit: false, so an open split no longer adds its two sessions on
top of a set already sized to the group, the count and the window.
- Touch-primary devices (primary pointer coarse: iPad, Android tablets)
default the Tiles button OFF in getDefaultSettings(); touchscreen
laptops (fine primary pointer) keep the desktop default ON. The button,
the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key
through these defaults, so they agree. CLAUDE.md and the invariants doc
say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two lines the #541 parity commit edited still called the split "desktop-only"
and listed "Phones and tablets" as a tile grid non-goal, right next to the new
note that a wide Android tablet clears the gate. The same commit documents
the gate as width alone in terminal-tile.js and architecture-invariants, and
that is what the code does: terminal-split.js and canOpenTileGrid in
tile-grid.js only compare window.innerWidth with SPLIT_PANE_MIN_WIDTH.
CLAUDE.md's Split-pane line now reads "desktop-only at 1180px (width alone,
so a wide Android tablet clears it)", in step with the Tile grid line, and the
tile-grid-plan non-goal names phones only and says a wide tablet or an
unfolded foldable in landscape can reach the grid, pointing at the keyboard
exception below it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tile's hand-encoded click report went through _handleDesktopTerminalClick
and _sendSyntheticSgrTap to _sendInputAsync, so it took a seq, was persisted
and would be redelivered after a reload. The documented TerminalTile rule
(CLAUDE.md, Split-pane sessions) is that only typed input enters that queue
and focus/mouse reports go out ephemeral, and the tile's own _onTerminalData
says the same. Before #555 an opencode tile's click went through xterm's
encoder and that ephemeral path. A click still unacknowledged when the page
reloads, or sent during a server restart, could be replayed onto a later
screen, where a press+release can pick a dialog option.
_sendSyntheticSgrTap now takes an opt-in `ephemeral` field on its target and
sends through _sendInputEphemeral when it is set; _handleDesktopTerminalClick
passes the target through unchanged, and TerminalTile._installClickListener
sets it. Without the flag nothing changes, so the primary pane's own click
and touch tap reports stay on _sendInputAsync exactly as before (whether the
primary pane should also go ephemeral is a separate question, out of scope
here).
Tests: the tile case now requires a frame with no seq and nothing pending in
the reliable queue, and the targeted-click case in terminal-touch-tap spies on
both send paths: a target with the flag goes ephemeral, an untargeted click
and an untargeted tap stay durable. Dropping `ephemeral: true` from the tile,
or the branch in _sendSyntheticSgrTap, turns the matching test red.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile counts as hollow when every row above its screen is its own overflow
(baseY minus _overflowRows is 0), so unlike the primary pane, whose hollow
buffer has baseY 0, its viewport can sit above the bottom while it is hollow:
Shift+PageUp, a scrollbar drag or a wheel during the first replay leave it up
there. _maybePageCliTranscript never looked at the viewport, so every wheel,
wheel-down included, was turned into PageUp/PageDown and swallowed. xterm never
scrolled back, the stale rows stayed on screen while the CLI paged out of
view, and clicks were dropped too, because the click report refuses an
off-bottom viewport.
The tile now pages only while _terminalViewportAtBottom holds for its own
terminal, checked before the pending travel is touched. Off the bottom the
wheel stays with xterm, so a wheel-down brings the viewport home and paging
resumes from there. The primary pane is unchanged: its hollow test already
implies a viewport at the bottom, which the twin comment now says.
Tests: a unit case for a tile hollow by the discount with its viewport above
the bottom (no page key, no preventDefault, and no travel carried over once
back home), and the real-browser case now scrolls a hollow tile up and proves
a real wheel-down scrolls xterm home with no page key sent, then pages again.
Both go red with the gate removed, and the unit case also with the gate moved
below the pending-travel update.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#541 fixed Android autocorrect duplicating the typed line in the primary
pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on
space (delete a word, insert the corrected one) sent the whole line again.
The fix, an edit-based diff that sends one DEL per deleted code point and
then the inserted text, lives in terminal-keycode229-recovery.js together
with #441's next-keydown drain (a character committed in the same task as
Enter goes out ahead of the \r) and the original orphaned-insertText
recovery. Only the primary pane created that controller, so a grid tile or
the split's Pane B still ran xterm's stock behaviour. Both are gated on
width alone (1180 CSS px), which a wide Android tablet clears.
TerminalTile now creates its own controller in connect(), after the xterm
opens and before the first await, handed this tile's textarea, this tile's
CompositionHelper and _onTerminalData as the send path, so recovered bytes
go to the tile's own session through the exactly-once queue. As in the
primary pane, handleKeyEvent runs first in the custom key handler, above the
keyCode-229 early return, and notifyCanonicalData sits in the onData lambda,
gated on the same two CodemanTerminalInput predicates, never in
_onTerminalData, which the recovered bytes also take. destroy() tears the
controller down before disposing the xterm, which restores xterm's own diff
and removes the capture listeners. No mode or device gate, matching the
primary. The module itself is unchanged apart from its header; terminal-ui.js
gains only a comment naming the twin.
Tests: test/terminal-tile-input.test.ts now loads the real module into its
vm harness (with window timers, without which create() would silently throw
and every test would run against no controller) and drives a fake
CompositionHelper carrying xterm's own append-only diff. It covers install
and restore on the tile's own helper and textarea, autocorrect sent as an
edit (with a control reproducing the device-log duplicate), the last
character and an autocorrect each followed by Enter in one task, a
self-rescued 229 key delivered once, the onData gate ignoring query replies
and focus reports, two refused inserts after one keydown both recovered,
robustness when the controller throws, per-tile controllers, and a source pin
keeping the call above the early return. Removing the create, the
handleKeyEvent call, the notify, its gate, or the destroy each turns at least
one of them red, as does moving the notify into _onTerminalData. The browser
suite gains a TerminalTile block in
test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted
execCommand input, chunks asserted to address the tile's session, with a
destroyed-controller control).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#555 made the primary pane page opencode's transcript with PageUp/PageDown
from the wheel, because opencode draws in place on the alternate screen and
leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile,
the split's Pane B) left every wheel to xterm, so in an opencode tile the
wheel scrolled nothing, or only stale rows.
The tile now runs the primary pane's own gates aimed at itself (its terminal,
its session, never the active one): xterm's tracking mode, the Claude
forwarding gate, then the hollow-buffer test. A wheel that passes them is
consumed in the capture phase and turned into PageUp/PageDown through the
shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin
of the primary pane's queue) and sent ephemeral on the tile's own socket.
Every other wheel stays with xterm as before, the shell history pull
included. The file names no CLI: the mode rules stay in terminal-ui.js, and
terminal-tile.js joins the frontend no-id-branching guard.
A plain port of the primary's baseY === 0 test would almost never fire in a
grid. A tile's first capture is taken at the PTY's previous size (usually the
taller primary pane's) and written into a shorter xterm, and its own
row-shrinking fits (zoom-out, divider drags, tile count changes) push more
rows above the screen. The tile counts those rows as its own overflow: all of
them after a load whose capture held a single screen (the server's
captureRows), plus whatever a local fit or a PTY geometry report pushes up,
reset by a clear and clamped to baseY. The paging gate gets baseY minus that
count. Output that scrolls real lines still counts as history, so the tile
stops paging there.
#555's other half, stripping opencode's mouse DECSETs so a drag selects text,
is server-side and already reached tile sockets. It also left the tile's
xterm unable to encode opencode's clicks, so the tile now installs the
primary pane's desktop click report (bubble phase, gated on the session's
cliMouseTracking, the tile's own link hover and selection). Both listeners,
the flush timer and the page-key state are torn down in destroy().
Still out of scope, as the fileoverview now says: touch paging (tiles have
no touch path) and SGR wheel forwarding to Claude's fullscreen renderer
(tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the
wheel to xterm.
Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm
harness (session targeting, every no-page case, accumulation, the cap,
coalescing, byte parity with the primary pane, the overflow discount through
a load, a fit, a geometry report and a clear, the click report and destroy);
the discount cases fail with it removed. The fake xterm gains opt-in row
emulation. test/terminal-tile-scroll.browser.test.ts checks the same model
against a real xterm with trusted wheel events (browser suite, not the gate).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The primary pane's hollow-buffer paging (#555) and its desktop click report
read this.terminal and this.activeSessionId throughout, so a second pane (a
grid tile, the split's Pane B) could only get them by copying the gates and
their CLI rules. They now take an optional trailing target instead, the
pattern registerFilePathLinkProvider, copyTerminalSelection and
_handleImagePaste already use for tiles:
- _shouldForwardWheelToApp(ev, { terminal, sessionId })
- _localScrollbackIsHollow({ terminal, sessionId, localRows }), where
localRows stands in for baseY so a tile can discount rows it pushed above
the screen itself
- _handleDesktopTerminalClick(ev, { terminal, sessionId, linkHovered }),
_sendSyntheticSgrTap(x, y, target), _shouldReportMouseToCli(sessionId),
_terminalViewportAtBottom(terminal) and _clientPointToCell(x, y, terminal)
Every field left out means the primary pane's, and every existing caller
passes none, so the primary pane behaves exactly as before and its
grep-pinned call sites are unchanged. The mode list for hollow buffers and
the claude >= 2.1.187 forwarding gate stay in terminal-ui.js alone.
The stateless math moves into two pure exports on CodemanTerminalInput,
wheelDeltaLines and pageKeysForTravel, which _wheelScrollLinesFloat and
_maybePageCliTranscript now delegate to. Comments on both sides name the
tile's twins (the page-key pager and the 40 ms coalescer).
Tests: the exports agree with the primary pane's methods and bytes, the gates
read the target's session, buffer, rows and tracking mode rather than the
active ones, and a targeted click uses the target's geometry, selection,
scroll position and link hover.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Thirteen contributor PRs, each re-checked against its GitHub head, merged
with its own merge commit and landing fixes, reviewed, and gated together
(524 test files, 10194 tests): #559, #552, #550, #556, #551, #546, #542,
#540, #555, #543, #541, #502, #432.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move the nightly browser-suite cron from 03:23 to 03:29 UTC, authored as
Ark0N. GitHub sends scheduled-run failure notices to whoever last modified
the cron line, but its docs do not say whether that means the commit author
or the pusher. The previous cron edit (02c65e98) was authored under the
maintainer identity, whose noreply@anthropic.com address GitHub resolves to
the unrelated login "claude", so under the author reading the nightly's
failure notices would never reach the maintainer. With this commit the
author, the committer and the pusher are all Ark0N, so every reading lands
on the maintainer. Only the minute changes; no doc or test names a clock
time.
After the first scheduled run on master, confirm with
gh api 'repos/Ark0N/Codeman/actions/runs?event=schedule&per_page=1'
--jq '.workflow_runs[0].actor.login', which should print Ark0N.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The same Tile Grid subsection and GIF as the English README, in the zh-CN
README under 多会话仪表盘, using the app's own zh-CN terms (平铺 for the
feature and its button, 窗格 for a tile, 平铺网格 for the grid).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Tile Grid subsection under Multi-Session Dashboard with an 800px GIF of the
real Tiles button opening six live sessions side by side and closing back to a
single session, recorded from the 1.36.0 release candidate. Identity text in
two terminals is covered by bars; the GIF was OCR-checked for it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Run dropdown scroll browser test built WebServer on the fixed port 3290,
which the static port guard (test/test-ports-guard.test.ts, from #556) rejects
for any file outside its shrink-only legacy list, so the CI gate failed on the
landing branch. The test now binds an ephemeral port with new WebServer(0, ...)
and navigates to server.boundPort, and its header records the new convention.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pin the App Settings codexModel guard to the schema. The 28df21f4 landing fix
added a client check in saveAppSettings() that copies the pattern of
SettingsUpdateSchema.codexModel, so one bad character no longer 400s the whole
.strict() settings PUT behind a "Settings saved" toast. Nothing tied the copy
to the schema: a looser copy would bring the silent 400 back, and a stricter
one would refuse valid model ids.
The new test extracts the client pattern from the saveAppSettings() body,
checks it agrees with the schema on eight samples (empty, dotted, slashed,
colon, space, semicolon, leading dash, non-ASCII), and asserts the guard runs
before the localStorage write. Length is left out on purpose, since the
input's maxlength="100" covers .max(100). Both a loosened pattern and a guard
moved after the write turn the test red.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Applies the review's landing list for the native-wrapper window bridge, with the verifier corrections.
detachSession now refuses before asking the host when there is no window channel (no BroadcastChannel). Without the channel there is no roll-call liveness, so a hosted tab could never re-dock and would stay detached, and excluded from tiles and split, until the session ended. The guard sits before the host call so a channel-less host never gets a native window and a window.open as well.
A single resolver, tabDetachButtonEnabled(), now lives in app.js next to hasHostWindows() and decides the host-aware pop-out default for the tab icon, App Settings and the tab action menu (which also serves the tile grid's menu). Before this the menu read the raw setting and hid "Open in a new window" under a host. The menu and both settings-ui.js sites call it optionally with a fallback, because test/session-sidebar-ux.browser.test.ts loads tab-rail-resize.js onto a bare CodemanApp without app.js, and a bare call would throw before the menu is appended.
The "Close window" button on the solo session-gone overlay goes through _closeSoloWindow(), as the re-dock button already did, so it works in a host window.
openWebviewExternal no longer falls through to window.open when the host refuses (in a WebView that can replace the dashboard page); it toasts instead, like the session and file-preview paths.
The hasHostWindows and openInHostWindow JSDoc now say what the code does: anything but false counts as opened, and a saved web tab passes its own origin.
docs/versioning-policy.md lists the window.CodemanHost bridge under experimental surfaces, so it does not read as a stable contract until the wrapper docs section lands.
test/host-window-detach.test.ts gives the harness a live window channel (Object.create leaves it undefined, which the new guard would refuse) and pins the no-channel refusal.
The per-PR changeset is removed; the release writes one consolidated changeset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Skip zero-size spreadsheet cells in renderTile. On sheets past the
8,000,000 px scroll cap, a cell clipped to nothing at the spacer's edge
(or a visible row or column the worker clamped to 0 px) was still created,
and the cell padding and border drew it as a 5 px box below the spacer
that grew the scroll area. The size is now computed before the element is
created and such cells are skipped, the same way the heading loops already
skip 0 px rows and columns. spreadsheet-preview.js is not an input of
SPREADSHEET_ASSET_VERSION, so the asset token stays valid.
Add zh-CN entries for the static spreadsheet preview strings (loading,
too large, no visible worksheets, empty worksheet, the warnings label,
timeout, failure, the four parser start and message failures, and the
unavailable message from panels-ui). The file-preview body is not a
skipped surface, so the exact-match entries apply with no code change.
The worker's admission refusal messages and the dynamic status message
stay English for a follow-up.
docs/security-architecture.md described the attachment gate as a
6-extension allowlist; it now names SUPPORTED_ATTACHMENT_EXTENSIONS in
src/attachment-registry.ts and what it covers, including the xlsx this
PR adds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A composition that ends in the same task as an Enter keydown was sent
twice. The keydown settled the pending edit (sending the composed word
and setting _dataAlreadySent), then xterm's own keydown finalized the
composition synchronously through _finalizeComposition(false), which
ignores _dataAlreadySent and sent the word again. settleEdit() now takes
the keydown event and, while xterm has a composition in flight
(_isSendingComposition), leaves the text to xterm for any key that makes
it finalize synchronously. On 229, CapsLock and the modifiers xterm keeps
the composition on its async path, which honours _dataAlreadySent, so the
edit still applies there. The waiting timers are cleared before that
early return, so a timer cannot fire after Enter's textarea clear and
send a run of DELs.
The guard sits in settleEdit(), not in applyEdit() as the bot proposed.
In applyEdit() it would also silence the timer path, where xterm always
finalizes asynchronously and skips _dataAlreadySent, so a non-composing
character typed just before a composition (the x in xword) would be lost
where master and the PR head both deliver it.
Two unit tests pin it, both measured: one fails without the guard
(the Enter keydown sends 'ab word' instead of 'ab '), and one fails with
the guard moved into applyEdit() (the timer path sends 'ab ' instead of
'ab xword'; a 229 settle must also still send the edit).
The xterm private-API guard test now also checks the bundle still ships
_isSendingComposition, and names it in its failure message and comment.
CLAUDE.md: the surviving #441 sentence said a keydown decides before
xterm's 229 rescue has run and that Enter's clear makes the pending diff
emit nothing. Neither holds any more (the edit diff is settled first, and
master already sent one DEL there), so it now says the edit diff is
settled first at that keydown. The PR's sentence notes the composition
exception.
The PR's own changeset is removed; its text goes into the single
combined release changeset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A lone repository git could not read rendered as a clean, empty one. The
panel took its single-repository view whenever the overview held one row,
and the error row only exists in the list view, so it showed "Nothing
uncommitted / No remote configured" with an empty header while the
indicator said "? 1". The single-repository view now needs a readable
repository and an untruncated overview; anything else takes the list view
(headed "1 repository"), and the tooltip names the unreadable repository
instead of saying "no branch".
The same condition covers a limit of 1 in a folder of several projects,
now that max repositories can go down to 1: the one row shown keeps the
"Showing the first" notice instead of looking like the only repository.
A repeated timeout query parameter reaches the route as an array, and
calling trim() on it answered 500 with an internal message, before the
ownership check. The route now treats a non-string timeout as "default",
like an empty or absent one, and the route test pins it.
The browser test gains the lone-unreadable-repository case (error row,
no "Nothing uncommitted", "? 1", tooltip names the repository) and the
truncated single-row case. Both fail against the unfixed panel.
The git timeout input steps by 1, not 5: the save accepts any whole
number of seconds and step 5 flagged values like 7 as invalid.
Docs: api-reference says repoLimit is only present in the
folder-of-projects case, the Settings Reference and Working With Files
glyph lists mention "? N", and the module header says the repository
count is the caller's maxRepos.
The PR's own changeset is removed; its text goes into the single
combined release changeset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment and doc corrections that #555 made stale, no behaviour change.
- stock.ts: the grok and omp altScreen comments compared their strip to
opencode's, which is now strip-mux-and-mouse rather than the narrow
strip. Grok now says it shares antigravity's strip until measured, and
omp drops opencode from its comparison.
- terminal-ui.js: the touch-tap comment named Claude/Codex/Gemini as the
stripped modes, but the gate is now the cliMouseTracking flag alone and
covers opencode too, so it names the two stripping flavours instead.
- src/types/session.ts: the cliMouseTracking JSDoc (the flag the browser
now gates on exclusively) listed only claude/codex/gemini; it now names
the strip-full and strip-mux-and-mouse modes, including opencode under
tmux.
- src/session.ts: the usesMux getter doc now names isMuxMouseStripMode,
since the replay strip passes usesMux to it as well.
- docs/architecture-invariants.md: the narrow-strip list gains
grok/deepseek/omp (matching the PR's own CLAUDE.md line), the
"must REMEMBER" heading covers both DECSET-stripping flavours, and the
cliMouseTracking writer is described as the full-or-mouse branch it
really is.
- docs/wiki/The-Dashboard.md: the user manual said every non-Claude CLI
scrolls locally; opencode's wheel and swipes now page its conversation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move the nightly cron from 03:17 to 03:23 UTC. GitHub sends scheduled-run
failure notices to whoever last modified the cron line, and after the merge
that is the contributor, so a maintainer commit has to touch it. The
docs below give no clock time, so they cannot drift from the cron.
Drop the "Keep the failure artifacts" step and the blank line before it.
No browser test writes test-results/ or screenshots-echo-diag/ (only the
ignore files name them), and if-no-files-found: ignore made the step upload
nothing without a word. The run log already carries the failure output.
Reword the workflow header. Drop the claim that the skipped suite let two
semantically conflicting PRs merge green: that incident came from
test/mobile/keyboard.test.ts, which this job does not run. Correct the
codex-predictive-echo note: the test uses a fake key in a throwaway
CODEX_HOME and skips itself when codex is missing, so it needs a codex
binary, not an authenticated one.
opencode-resize: record WebSocket resize frames under the socket's own URL
instead of appending '#' + the session id. The URL already carries
/ws/sessions/<id>/terminal, and the suffix let toContain(sessionId) pass for
a resize sent on any session's socket, the bug this test exists to catch.
Reduce the six session-id extractions (opencode-resize and perf-browser) to
data.data?.session?.id. POST /api/sessions always answers in the
{ success, data: { session } } envelope, and the dead fallbacks are what
hid the original breakage.
split-pane-terminal: restore the browser config's 60 s test timeout (the
added 20000 ms override tightened it), and replace the comment that blamed
Codeman's post-create clear. Under vitest the session is an echo PTY, so
that clear comes back as text; the real fix is useMux:false, since a plain
prompt otherwise goes through tmux send-keys, which test mode no-ops.
CLAUDE.md: the CI note now says the gate excludes the Playwright tests in
BROWSER_TEST_GLOBS instead of a stale count of 14, and names
browser-suite.yml; the Testing warning says the browser suite runs nightly.
CONTRIBUTING.md gets the same one-line pointer under Tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow the keyboard: the menu's cap now uses var(--app-height, 100dvh)
instead of 100dvh. The viewport meta has no interactive-widget, so dvh does
not shrink for an on-screen keyboard, while MobileDetection always sets
--app-height to the visual viewport height and KeyboardHandler keeps it there
while the keyboard is up (body and .app already size the same way).
Subtract both safe areas from the cap: in the iPhone home-screen app the
phone header grows by the top inset and the toolbar sits above the bottom
inset, so without them the top of a full menu slid under the fixed header.
Add overflow-x: hidden. overflow-y: auto computes overflow-x to auto, so a
long nowrap custom-endpoint label showed a horizontal scrollbar that
touch-action: pan-y cannot pan (the same trap the file documents for
.run-mode-history).
Raise the toolbar while the Run menu is open, by adding
.toolbar:has(.run-mode-menu.active) to the existing popover raise rule. The
menu is trapped in the toolbar's stacking context, so on a touch device the
keyboard accessory bar (z 51) and the visible CJK input (z 52) covered its
last rows even when scrolled to the end. This follows the rule the case
settings popover and case combobox already use.
Reword the rule's comment: it claimed dvh follows the keyboard and that the
vh line is a fallback, and neither is true (a declaration carrying var() is
never dropped at parse time). The new text has no braces and no max-height
text, which the gate test's rule() slicer depends on.
Pin the fixes in the gate test (the --app-height and safe-area terms,
overflow-x: hidden, the toolbar raise) and retitle the cap test so it no
longer names dvh as the mechanism. The test now strips CSS comments before
reading the rule, since the rule's own comment names overflow-y: auto and
touch-action: pan-y and would otherwise keep those assertions green after the
declarations were deleted (checked by deleting them: the test now fails).
Drop .changeset/run-menu-scroll.md: it repeated the false dvh claim, and the
release writes one consolidated changeset at COM.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
App Settings now refuses a Default Codex model that the server would reject,
before anything is written to localStorage. SettingsUpdateSchema is .strict()
and checks codexModel with ^[a-zA-Z0-9._\-/]*$, so a value like gpt-oss:20b
400'd the whole settings PUT while the toast still said "Settings saved", and
because the bad value was already in the local blob every later save from that
device failed the same way. The client check uses the same pattern, shows an
error toast, focuses the field and keeps the modal open. The toast has a zh-CN
translation in i18n.js.
src/web/codex-launch-defaults.ts gets an @fileoverview (fill only unset fields,
re-validate persisted values, callers decide scope, never writes Codex config
files), as every module in src carries one.
Both new Codex rows in index.html carry has-field, like every other App
Settings field row, so on phones the input and the select stack under their
label instead of squeezing it into a narrow column.
The Agent CLIs wiki paragraph said the defaults apply to every local launch.
Scheduled (cron) codex jobs are built without a codexConfig and never get
them, while Resume goes through POST /api/sessions and does, so the sentence
now names the Run menu, Resume, POST /api/sessions and /api/quick-start, and
says cron jobs do not use them.
The Settings Reference lists the two new rows in the Agents & CLIs table. The
neighbouring "Bypass approvals and sandbox" row described Pi's project trust;
it is the Codex --dangerously-bypass-approvals-and-sandbox toggle, so its note
says that now.
The PR's own changeset is removed: the release writes one consolidated
changeset at COM, and the PR's text overstated the scope (it included cron).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pin the deprecation warning on every /api/screenshots route. The PR's test
sends two GET /api/screenshots requests and checks that exactly one warning
comes out, which proves the once-flag but not the individual calls: the
POST and GET /:name warn calls could be deleted and it would stay green. A
new it.each sends one request per route (GET list, a non-multipart POST that
reaches the handler before the content-type check, and GET /:name for a
missing file) on the fresh per-test harness, and each case asserts exactly
one warning naming POST /api/sessions/:id/paste-image. Removing any single
warn call now fails its own case (checked by deleting each call in turn).
The deprecation's CHANGELOG note, required by docs/versioning-policy.md for a
deprecated covered surface, rides the consolidated release changeset rather
than a file here, since the PR added none.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move test/sse-tile-grid-filter.test.ts to an ephemeral port. The release
added it with #561 on fixed port 3287, after #556 was cut, so it is not on
the guard's LEGACY_FIXED_PORT_FILES and test/test-ports-guard.test.ts failed
on the merged tree. It now builds new WebServer(0, ...) and its url() helper
reads server.boundPort (only ever called inside tests, after beforeAll).
Converting it is preferred over listing it, since the legacy list is
shrink-only.
Update the five docs that still told contributors to pick a unique fixed
port, which the new guard now rejects for any WebServer test: CLAUDE.md
(Adding Features and Testing), AGENTS.md, .github/CONTRIBUTING.md and the
wiki's Contributing page (mirrored to the public GitHub wiki). They now say
to bind port 0 and read boundPort (or address().port for a raw server), and
note that the mobile suite keeps its fixed ports for now, because
test/mobile/helpers/server.ts caches servers by port, so createTestServer(0)
from two callers would share one server.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An opencode tile showed only the session name, while Claude Code, codex and
DeepSeek tiles add `· <model>`: opencode declared no `modelDetect`, so its
screen was never read for a model and it is launched without a model param.
opencode draws the model on its composer's agent row, directly above the
box's bottom edge: `┃ Build Big Pickle OpenCode Zen`. Read from its own
1.3.0 source, the row is the agent, the model's name, the provider's name and
`· <variant>` when the model has one, and only colour tells model from
provider. So the field is all of it, exactly what opencode itself shows (the
owner's choice over a short id that only appears after the first reply).
- The pattern anchors on that row sitting directly above the `╹` edge, ends
the field at a double space (where the 200-column layout's sidebar shares
the row), skips the `No provider selected` placeholder, and takes the LAST
such row in the window through a lookahead, so a composer-shaped row the
agent prints higher up can never stand in for it. A test with a forged pair
inside the window fails without the lookahead.
- It reads 8 rows: the home screen puts up to five rows of opencode's own
chrome under the composer (key hints, a tip, the cwd/version row). The
schema's `screenLines` bound goes from 4 to 8, the reader's own cap; the
comment there records why a taller window is only safe with such a pattern.
- A permission prompt or shell mode hides the row; the last model is kept.
Measured against every captured opencode 1.3.0 frame (home screen and in
session, 40/60/120/200 columns, mid-turn and at rest, permission prompt):
the model was read everywhere it is drawn and nowhere else. Live on an
isolated instance from this branch, a restored opencode session published
`displayModel: Big Pickle OpenCode Zen` (source: screen) and its tile header
rendered `oc-home · Big Pickle OpenCode Zen`. The owner's own home-screen pane
on the 1.36.0 beta reads the same.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A gemini session stayed "working" for good after its first turn. Its braille
spinner trips the generic SPINNER_PATTERN and marks the pane working, but only
a composer glyph arms the idle confirmation and gemini declared none, so it
fell back to Claude's `❯`, which gemini never draws.
Measured on live Gemini CLI 0.63.0 panes (capture-pane every 300 ms through
real turns with a shell call at 40, 120 and 200 columns, YOLO and default
approval mode, plus the raw PTY stream). The turns ran against a local
stand-in for the Gemini API (GOOGLE_GEMINI_BASE_URL, which Codeman's custom
endpoint support already sets), since the CLI's TUI does not depend on the
backend and no account is needed for it:
- The TUI repaints its whole bottom region every frame, composer included,
and the composer sits between a `▄` bar and a `▀` bar; the submitted prompt
is echoed between the same bars. The `▀` bar arms the idle check: every
repaint carries it, tmux's reattach repaint too. The composer's prompt
character is no good: it follows the approval mode (`*` in YOLO), and its
`>` also starts the echoed prompt, which would make the submit verifier
read a submitted prompt as stranded and press Enter again.
- While a turn runs a line `⠦ Thinking... (esc to cancel, 6s)` animates about
every 80 ms (largest gap mid-turn: 214 ms). The label can be any loading
phrase, so the working line is the `(esc to cancel, <n>` suffix, or a
spinner frame opening a line for when a long phrase wraps that suffix.
Nothing at rest matches either.
- A tool confirmation (default mode) replaces the composer, stops the
spinner and the pane goes silent (3.9 s gap), so it reads as idle.
Verified on an isolated instance from this branch: a YOLO turn emitted one
session:working (+170 ms) and one session:idle (2.5 s after the last output);
a default-mode turn went working -> idle while the confirmation waited ->
working once allowed -> idle at the end; after a server restart four restored
gemini panes went busy -> idle in about 4 s; a fresh launch settled in 3 s.
The launch-settle and uncharacterised-CLI tests that used gemini as their
example of a CLI without work detection now use grok and deepseek.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A codex session waiting on a background terminal read as plainly idle,
with no "1 background terminal" badge. Codex pins that row above its
composer, and the registry looked for it in the last three non-blank
rows. Codex 0.162.0 added a hint row under the status line at rest
(` ← for agents · ? for shortcuts`), which pushes the chip to FOURTH from
the bottom exactly when the idle probe reads it. Measured live on the
1.36.0 beta with `sleep 600` started as a background terminal: chip,
composer, status line, hint row; the server reported `watching: null`.
While a prompt is typed the hint goes away and the chip is third again.
The codex entry now declares `watchingLines: 4`. The trade is stated in
the entry: with no terminal running, the fourth row from the bottom is
the last transcript row (the last two while typing), which the agent
writes. As before, this is contained by codex having no hooks: a forged
row costs a wrong badge, never a silenced alert.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A pi session showed no model in its tab or tile unless one was passed at
launch, and none at all for the default route. pi draws its model in its
own footer, but its registry entry declared no modelDetect, so the pane
probe never read it.
The footer, from pi 1.1.0's footer code (0.84.4's is the same) and a live
pane (`0.8%/253k (auto) qwen3.8-27b-pi • xhigh`): usage and context
on the left, then at least two spaces and `[(provider) ]<model>`, with
` • <thinking>` for a reasoning model and ` → <routed model>` when
routed. The last two rows are read (an extension status row can sit
below), and the context field picks the stats row out of them.
pi truncates the right side to fit a narrow pane with no ellipsis,
leaving exactly two spaces of padding. A name with nothing after it is
therefore read only with three or more spaces in front, and with two only
when a following ` •`/` →` proves it whole, so a cut-off name is never
shown. `no-model`, pi's placeholder, is rejected.
The read rides the idle confirmation pi gained with its workDetect entry.
Verified on an isolated instance: a fresh pi session published
qwen3.8-27b-pi (source screen) about 8 s after launch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every codex tile and tab showed no model, unlike claude and deepseek.
Codex reports its model only in the footer under the composer
(` GPT-6-Luna default · ~/codeman-cases/testcase`), and the registry read
the pane's LAST row for it. Codex 0.162.0 added a hint row under the
footer at rest (` ← for agents · ? for shortcuts`, or ` ? for
shortcuts`), so the last row was always the hint and `displayModel`
stayed null. Measured live on the 1.36.0 beta: the hint is there at rest
and after a turn, and gone while a prompt is being typed (the footer is
the last row again then).
The codex modelDetect window is now two rows, and the footer must be
either the last row or followed by exactly one more two-space-indented
row. Anchoring to the end of the window keeps the guard the one-row rule
had: with the footer hidden, the last two rows are a transcript line and
the `›` composer, so a footer-shaped line the agent printed is not read.
Tests cover both 0.162 hint variants, the typing layout, the hint never
read as a model, and a forged footer-plus-indented pair above the
composer.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An omp session stayed "working" for good once a turn started, the same
latch pi had: omp's braille spinner trips the SPINNER_PATTERN fast path,
and only a composer glyph arms the idle confirmation. omp declared none,
so it fell back to Claude's `❯`, which omp never draws once its setup
wizard is done.
Measured on live omp 18.8.6 and 18.0.11 panes, holding a turn open
against a local endpoint that never answers: the input row is `╰─ <text>`
and is redrawn at submit, at the end of a turn, at launch and on
reattach. While a turn runs, the status bar's leading `π` becomes a
braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > ...`; 18.0.11
pads it with two spaces, past a minute it reads `1m`), with a
`⎋ Working…` row above it. The registry entry now names the input row as
the glyph and either working signal as the working line.
The glyph also switches the submit verifier on for omp, which reads the
input row the way it reads Claude's composer. A prompt sent mid-turn goes
to omp's Steering queue and clears the row, so the verifier stands down.
Text left in the row after an Enter is the one case it re-presses.
Verified end to end on a sandboxed instance (own HOME and PATH, omp
18.8.6): session:idle at launch, session:working during a turn,
session:idle about 3 s after it ended, and a restored pane settled idle
about 3 s after a server restart.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An opencode session that ran a tool stayed "working" for good. The running
tool row draws a braille spinner (`⠋ Sleep for 12 seconds...`), which trips
the generic SPINNER_PATTERN and marks the pane working, but only a composer
glyph arms the idle confirmation and opencode declared none, so it fell back
to Claude's `❯`, which opencode never draws. Measured on an isolated
instance: a 16 s turn latched busy/isWorking for the rest of the session.
A text-only turn had the opposite problem and never showed as working.
Measured on live opencode 1.3.0 panes (capture-pane every 250-300 ms through
real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream):
- Every composer row starts with a `┃` bar, and the submitted prompt lands in
the transcript with the same bar, so a turn's first repaint arms the idle
check, and tmux's reattach repaint does the same for a restored pane.
- While a turn runs the footer row starts with an 8-cell knight-rider
spinner, `⬝■■■■■■⬝ esc interrupt`, redrawn about every 40 ms (largest
gap mid-turn: 121 ms). At rest the TUI is silent and nothing on screen
draws a `⬝`/`■` run, the 200-column sidebar included.
- The working line is the spinner run, `[⬝■]{8}`, not the label: tmux ships
`esc` and `interrupt` as separate words joined by cursor moves, so the
label never reaches the stream detector, and at 40 columns the footer
wraps it to `esc` / `interr` / `upt`. All 344 spinner chunks of a turn
match the run after Codeman's ANSI strip.
- A pending permission prompt replaces the composer and stops the spinner,
so it reads as idle (waiting on the user).
- The last `┃` row on screen is the composer's agent/model row, or the
permission box's closing bar, never the prompt text, so the submit
verifier stands down and can never press Enter into a dialog.
Verified on an isolated instance from this branch: a 15 s tool turn emitted
exactly one session:working (+271 ms) and one session:idle (3 s after the
spinner stopped); a permission prompt read idle and the allowed turn went
working -> idle; after a server restart both restored opencode panes (one
at rest, one on a permission prompt) went busy -> idle in about 4 s; a
fresh launch reached an open page as idle in 3 s.
The launch-settle tests that used opencode as their example of a CLI
without work detection now use gemini and antigravity.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
40560ace made the 3 s launch settle announce its idle. For a CLI with
work detection that edge could now land in the middle of a turn: a
prompt sent ~2.9 s after launch has not been marked working yet (the
working line goes through the deferred parsers), so the settle called
the pane idle and a send-and-wait registered for that prompt resolved
before the turn even started. Before 40560ace the settle was silent, so
this edge is new.
The settle now also leaves a pane to `_confirmIdle()` when a prompt was
submitted since the timer was armed, the same way it already does for a
pane marked working; that confirmation reads the screen before it ends
the turn. A CLI without work detection still settles: nothing else ever
would.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A pi session stayed "working" for good once a turn started. pi's braille
spinner trips the SPINNER_PATTERN fast path, which marks the pane working,
but only a composer glyph arms the idle confirmation and pi declared none,
so it fell back to Claude's `❯`, which pi never draws. Measured on beta136:
an errored turn stayed busy/isWorking for 3+ minutes after pi was back at
rest.
pi has no composer glyph. Measured on a live pi 1.1.0 pane (capture-pane
every 250 ms through a turn): its composer sits between two `─` rules, and
while a turn runs it rewrites the top rule as `── ⠏ Working ───` on every
frame. The registry entry now names the rule as the glyph that arms the
check and a spinner frame inside it as the working line.
The same glyph settles a reattached pi pane (a restored pane gets no launch
timer): tmux's reattach repaint carries `─`, which arms the confirmation.
The submit verifier reads the last rule, finds no prompt text and stands
down, so it can never press Enter on a pi pane.
Verified on an isolated instance from this branch: a real pi turn emitted
session:working then session:idle, and after a server restart the restored
pi pane went busy -> idle in about 3 s.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A fresh codex, pi or opencode tile could spin "working" forever while the
pane sat at its composer. The external-CLI launch timer set the status to
idle WITHOUT an event, only `needsRefresh`. When the launch paint never
marked the pane working, the later idle confirmation found the status
already idle and announced nothing either, so every open browser kept the
`busy` from the spawn broadcast. A reload fixed it, which is why only open
pages were stuck. Measured on the 1.36.0 beta: w8 (codex) had
`lastPromptTime: 0`, i.e. no idle edge ever, and a page held a fresh pi
session at `busy` while GET /api/sessions said `idle`. pi and opencode hit
it on every launch (no work detection, so the timer is all they have);
codex only when its launch paint lost the race against the timer.
- `_concludeIdle()` is now the one place a pane is concluded idle: status,
working flag and prompt stamp change together and `idle` is emitted
(session:idle + state broadcast). `_confirmIdle()` uses it too.
- The launch settle (`_settlePaneStartup`) concludes a pane still in its
spawn-time `busy`. A pane already working is left to `_confirmIdle()`
only when its CLI declares `capabilities.workDetect`; for the rest the
timer is the only thing that can settle it, so it also clears a working
flag a launch spinner glyph latched.
- `_armPaneSettle()` also arms it for a RESTORED pane (Codeman restart,
auto-reattach, tile Attach) of a CLI without work detection (at this
commit shell, opencode, gemini, antigravity, pi, grok, deepseek and omp),
which used to stay `busy` server-side with nothing to clear it. Restored claude and
codex panes stay on their composer glyph, so a restart mid-turn is never
called idle. No `needsRefresh` there: an attach refetches by itself.
Pre-existing since the OpenCode integration, not a regression of this
release. Restore-path gap reported by the opencode and pi sessions working
the same symptom.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A claude tab drew no harness mark at all and every other agent CLI a
two-letter text pill (DS, CX, ...), so a claude tab read as "no harness"
next to its neighbours. Session tabs (header strip, side rail, sidebar,
phone chips) and the desktop home rail now draw the agent through PR
#532's run-mode-dot <id> slot, the id as data, the same mark the Run
menus and the tile and split headers use. The shell is not an agent and
keeps its SH pill; a CLI added through clis.json gets the slot's plain
dot instead of nothing.
The per-CLI tab pill colours and their light-skin ink overrides are gone
(the monochrome marks follow the tab's own text colour), and the logo
steps aside with the other adornments while a compact rail row renames.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Design B ("one primary + chips") won a two-judge panel over a launcher grid
and a command panel. Their defect list, fixed here:
- The tunnel spinner's track was the shared translucent white and vanished on
light skins; it now follows the link's own text colour.
- A running tunnel was only green text above its QR; it now shows as a quiet
green pill, and the resting link uses --text-dim (contrast).
- A long custom CLI label could push a horizontal scrollbar into the welcome
column: labels sit in a .welcome-label span that ellipsizes (still one text
node, so i18n.js matches it), and both buttons cap at the column width.
- Chips are 40px tall on coarse pointers (tablets reach this view).
- The OG primary's colours now name the toolbar Run rule they mirror.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The welcome overview drew every CLI as its own gradient pill (one colour,
gradient and glow per CLI, plus skin overrides that re-tinted three of them),
all at the same size with the same generic play icon, wrapping into an
unaligned cluster with Cloudflare Tunnel styled as one more launcher. Nothing
said which action mattered.
Now the screen has one obvious thing to press:
- A single primary button for the first AGENT in the registry catalog (Claude
Code on a stock install), in the accent fill the toolbar's Run button uses,
with the CLI's real logo on a small light disc (a brand mark straight on the
accent turns muddy) and the same translatable "Run <label>" text.
- Every other enabled CLI as a slim, uniform pill under it, in the Compact
header's language: control-bg surface, control-border, small type, the
logo from the shared run-mode-dot slot, the bare name, and "Run <label>" as
tooltip and accessible name. Catalog order, centred, wrapping when needed.
- Cloudflare Tunnel as a quiet text link under the launchers (same id,
handler and cloudflared gate; text colour carries the active and connecting
states), still directly above the QR it reveals.
The primary is chosen by catalog order and kind, never by an id, so with
Claude disabled the next agent takes the slot and a custom CLI listed first
gets it like any other. The buttons carry no per-CLI class any more: the id
travels only as data-mode and the logo slot, and all the per-CLI welcome
gradients and their skin overrides are gone. Everything is token-driven, so
it follows every skin; OG gets its own toolbar Run blue with light text
because its --accent-ink is an unused placeholder. Focus rings sit offset
from the fills, hover motion is off under prefers-reduced-motion, and the
phone rules (reached only with the phone overview off) span the primary and
keep the chips wrapping.
Tests: run-mode-ui pins catalog order across primary and chips, the
kind-based primary choice (Claude off, shell listed first, custom agent
first, shell only), the logo slot and absence of id classes, the token-only
CSS with no .welcome-btn rule left, and the tunnel link's place between the
launchers and the QR.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner asked to drop the "+" (Add Case) and gear (case settings) buttons
beside the toolbar case picker. Their two actions now close the picker's own
list instead, as "New or link a case…" and "Case settings…" rows in a sticky
footer, so nothing becomes unreachable: on desktop the "+" was the only door to
Add Case (create, link, clone, manage), and the gear the only one to the
per-case Agent Teams / 1M Opus overrides.
The rows are part of the arrow-key walk (after the last case) and Enter runs
them, they also show when nothing matches the filter, and the case settings
popover still opens anchored to the case group. The picker input gets its
right-hand corners back, and the dead .btn-case-add / .btn-case-settings rules
(desktop, phone and two skin selector lists) are gone. Phones keep their own
case sheet and gear.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner's picks after testing the 1.36.0 beta:
- Header Stats Style defaults to Compact (two pills with rings) instead of
Tiles. The resolver, the pre-paint stamp and the App Settings option all
agree; an unknown value now reads as compact.
- The Tiles header button is ON by default everywhere but handhelds (their
defaults object keeps it off, and the button still needs a 1180px window).
The Ctrl+Shift+G gate in tileShortcutFor() now resolves an absent key
through the device defaults too, so the chord and the button cannot
disagree; before, it required a stored true.
- Tab Layout defaults to Classic (the single strip, as before). By state, By
case and Ledger stay available as opt-ins. The tile-grid beta the owner used
last had only this layout, and it is the one they wanted back.
zh-CN option labels follow the new "(default)" markers; docs and wiki updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#538's by-state header strip lines its labels up in a column measured into
--tab-triage-gutter by _sizeTabTriageGutter(). Under 600px mobile.css hides
the headings (the phone row keeps its chips, no labels), but the sizer
measured them anyway: every part was 0 wide, yet `width + 5 * (parts - 1)`
made each labelled heading 5, so the "nothing to size" guard never tripped,
the column became 15px and the group key was cached as if measured. A phone
turned to landscape (>= 768px) or a foldable opened (Find N5: folded under
600, unfolded 1124 wide) then crossed into the wrapping strip with no tab
render behind it (the resize handler only calls updateTabOverflowMode()),
the key had not changed, and "WAITING 2" sat in a 15px column on top of the
first tab of its row.
Only the wrapping strip reads the column, so the sizer now measures nothing
and forgets its key while the strip does not wrap, counts only parts that
are laid out (a hidden heading sizes and keys nothing), and
updateTabOverflowMode() calls it right after deciding the wrap. Phones and
tablets therefore never measure (no layout read per render pass), and every
flip into the wrapping strip, the 768px breakpoint included, measures
afresh. The 600px breakpoint only matters below 768px, where nothing is
measured.
Tests in test/tab-triage.test.ts pin that hidden headings size and key
nothing, that the real updateTabOverflowMode() measures on the unfold with
no render, and that wrapping again re-measures with the counts unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On phones and 600-767px tablets the header strip stays one horizontally
scrolling row. #538 (Tab Layout, default "by state") orders that row in one
flex `order` band per state, so when the ACTIVE session changes state (a
prompt sent: idle to working; a permission prompt: needs you) its chip
moves to another band while scrollLeft stays put, and the tab in use left
the screen (measured at 390px: x 165 to -870). #257's reveal rules only
covered a CHANGED active tab: _updateActiveTabImmediate reveals on a
switch, and _fullRenderSessionTabs restores scrollLeft and re-reveals only
when _lastRenderedActiveTabId changed, while a state change is an
incremental pass that never reveals at all.
_noteActiveTabBand() records the active tab's band (read off the element,
so it is what is on screen) and reports when it moved while the tab stayed
active. Both render paths reveal on that, in the single scrolling row only
(_isScrollingTabRow: not wrapping, not a vertical list). It is keyed on the
band, not the raw order value, because another tab entering or leaving the
active tab's band shifts that value by one and another tab's move must not
yank a strip the user is browsing. _updateActiveTabImmediate records too,
so the pass right after a switch still counts (viewing a waiting tab spends
its alert and drops it into the idle row). The incremental path reveals
after updateTabOverflowMode(), and only from the branch that reached
_syncTabTriageChrome(), so a pass that falls through to a full rebuild
mid-loop leaves the record for the rebuild to compare.
Tests in test/tab-triage.test.ts pin the reveal on both paths and after a
switch, and that another tab's band change, a wrapping strip, a vertical
list and an active web tab never scroll.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#538 added three App Settings rows (Tab Layout, State Order, Header Stats
Style) with their descriptions and nine options, and none of them had a
zh-CN entry, while #561 in the same release translated every string it
put on screen. With the language set to Chinese those rows stayed English
in the middle of a translated settings page.
Adds the 15 entries next to the other tab-bar settings. The header
style's "Tiles" is 磁贴, not 平铺: 平铺 is #561's word for the tile grid
(the Tiles button and its setting), and "Tiles (default)" must not read as
the grid. The test runs the real index.html through the real translator
in JSDOM, checks every label, description and option of the three rows
(the shared `desktop` tag aside), that English is unchanged, that each key
is in the dictionary once, and that the strip's state-row headings
translate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#561 marks the tab of every tiled session with an inset underline
(`.session-tab.in-tiles:not(.active) { box-shadow: inset 0 -2px 0 ... }`,
0,3,0). #538's ledger draws each cell's status bar as an inset box-shadow
too (`.session-tabs-host > .session-tabs.tabs-ledger > .session-tab`,
0,4,0), so in the ledger the bar replaced the marker and nothing in the
strip said which sessions were on the grid.
The ledger now restates the marker beside its bar for a tiled, inactive
cell; the bar still follows --ledger-bar, so needs-you, waiting and exited
cells keep their colours. By state, by case, classic and the side rail
paint no cell shadow of their own and already showed it. The test scans
every arrangement-scoped rule that paints a tab cell's shadow and requires
an .in-tiles variant that keeps both, so a new arrangement cannot drop it
again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#538's Compact and Tiles header styles give every header icon button a box
through `html[data-header-stats] .header-right > .btn-icon-header` (0,3,1,
hover 0,4,1), which sets background, border and colour. That outranks the
open-state accent of #561's Tiles button (`.tiles-open`, 0,3,0) and of the
Split button (`.split-open`), hover included, so under the default Tiles
style an open grid or split looked exactly like a closed one and nothing
said the next click would close it.
The boxed styles now restate the pressed look for both buttons, hover
included (accent fill, accent border, accent ink), as comma-grouped
selectors built on the box rule's own selector so they always outrank it.
Classic is untouched and keeps the buttons' own rules.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#538's Compact and Tiles header styles carried two glyph rules of their own:
a `transform var(--transition-smooth)` transition on every header button's
svg, and `.btn-settings:hover > svg { rotate(45deg) }`. #561 had meanwhile
dropped the global button rotate and moved the hover motion onto the glyphs
(gear turn, folder, Tiles squares) behind `(hover: hover)` and a
prefers-reduced-motion off switch. The #538 rules out-specified those
guarded rules (they reach the glyph through `html[data-header-stats]
.header-right`), so under the default Tiles style a reduced-motion user
still saw the gear turn, a touch tap left it stuck at 45deg, and the 0.2s
ease replaced #561's spring.
Both rules go; #538 keeps only its glyph sizes, and the guarded motion now
applies the same in all three header styles. The hover test scanned only
selectors naming .btn-icon-header, which is how this slipped through: it
now scans every rule that reaches a header glyph and pins the one spring
transition and the hover guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With tabArrangement 'case' at 1440px (nine tabs in four cases), the lineage
routes ran horizontally through the middle of tabs, cluster labels and box
borders. #538's clusters are `max-width: 100%` boxes that may shrink, so in
the one-line strip every box squeezed and wrapped inside itself (a one-tab
case's swatch alone on a line above its tab). The strip then never
overflowed, so updateTabOverflowMode() never wrapped it, and #544's routing
room (`.lineage-tree.tabs-auto-wrap`: row gap and spine channel) never
applied. computeLineageRows grouped the squeezed tabs by top alone into rows
that overlap (10-40, 20-50, 42-74), and gapUnder put the first row's gap at
y 30, inside the tabs below it.
- On the desktop header strip a cluster keeps its width, so clusters that do
not fit overflow and the strip wraps box by box; the boxes' own gaps read
--lineage-row-gap, so the routes run between box rows and through the
spine channel. A case wider than the whole strip still wraps inside its
box, and now wraps the strip too (`_tabClustersWrapInside`, the new
`innerWrap` input of shouldAutoWrapTabs), so its inner rows get the gap.
- computeLineageRows never returns overlapping rows: tabs whose spans
overlap are one row. gapUnder returns null when there is no real gap, and
a route with no gap is not drawn. A final pass drops any route with a
segment inside a tab, so no arrangement can draw a line through a tab
(a layout without the room loses lines instead). Pinned with the squeezed
rects measured live.
At 1920px, where the clusters fit on one line, nothing changes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With tabArrangement 'state' (the default) and lineage lines on (the desktop
default), the spine ran straight through the WORKING / WAITING / NEEDS YOU
label of every row it passed. #538's
`.session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)` pads the
strip by the label gutter and pulls each label to the strip's edge with a
negative margin; it outranks #544's `.lineage-tree.tabs-auto-wrap`
`padding-left: 20px`, so the spine channel disappeared, and the spine,
anchored at the strip's left edge (computeLineageTree), sat in the label
column.
The channel now opens BETWEEN the label column and the tabs: with lineage,
the state strip pads by gutter + --lineage-spine-channel, every label keeps
its column at the edge (still under the brand) and hands the channel back
after itself, and the lead label still starts right after the brand.
computeLineageTree takes the channel's left edge as `spineLeft`, which
session-lineage.js reads back from the padding the CSS laid out (content edge
minus the channel), so the two cannot drift; without it, or where the channel
is the padding itself (classic, ledger, case), the spine stays at the strip's
edge exactly as before. The spine's clamp now only considers tabs in rows the
spine can run beside (every row after the first), so a first row that starts
left of the channel (a quiet idle group right after the brand) no longer
drags the spine back over the labels.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#544 reserves a 12px row gap for the lineage routes on
`.session-tabs.lineage-tree.tabs-auto-wrap`, and #538's arrangements set
their own row spacing at an equal or higher specificity later in the file:
- The ledger grid's `gap: 4px 6px` and the case strip's `gap: 6px` won, so
the lanes packed onto the cell borders (y 39/40/41 in a 4px gap).
- In the state rows every `.tab-triage-break` is a zero-height flex line of
its own, so each group boundary cost two row gaps: rows 54px apart instead
of 42, and the header 12px taller per group (including a trailing one after
the last group). A negative margin on the break cannot cancel it, because a
flex line's cross size is clamped at zero (measured in Chromium).
The lineage row gap is now one custom property, `--lineage-row-gap`, set only
by the lineage rule. The ledger and both cluster gaps read it with their own
fallback, and the wrapped state strip spaces its rows with a bottom margin on
every item except the breaks (row-gap 0), its last row's margin replacing the
bottom padding. Rows are now one gap apart in every case, with lineage and
without (4px then, where a group boundary used to be 8px).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
initTerminal() creates the ResizeObserver on #terminalContainer once per page,
and _resetAllAppState() (run by handleInit on EVERY SSE init, page load
included) disconnected and dropped it. From the first init on, only a window
resize refit the terminal, so anything that resized just the terminal box left
xterm at its old row count with the bottom rows clipped behind the toolbar.
Pre-existing since the March app.js module split, but this release makes it
constant: #538's state rows grow and shrink the header whenever a session
starts or stops working, and #544 reserves lineage room when the first child
appears. Measured on the beta: header 115 -> 170 px, container 743 -> 688 px,
xterm stayed at 35 rows (32 fit) until a tab switch. initTerminal() already
disconnects any previous observer before creating one, so the reset has
nothing to clean up.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two pieces the #428 merge left out or lost:
- Spacing: the removed #shellCount stepper's 0.25rem margins were the only
space between Run Shell and the case picker (the desktop .toolbar-group has
gap: 0), so the two touched. The case picker now keeps the same 4px itself
above 768px; at 768px and below mobile.css already spaces the group with gap.
- The phone case sheet's keyboard lift from #428's search commit. #488 shipped
the sheet's search field without it, so on iOS (which does not shrink the
layout viewport) the keyboard opens over the sheet and hides its own search
box. KeyboardHandler now lifts an open sheet like the toolbar (translateY on
phones, bottom on iPads), resetLayout() clears any sheet, and the list is
capped while the keyboard is up so the search row and Create stay on screen.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Shortcuts tab stores overrides in the per-device localStorage blob, and
saveAppSettings() carries them over from the previous blob, but the strip
before the PUT never removed them. SettingsUpdateSchema is .strict() and does
not declare the key, so once a device had any override (even the empty {}
that Reset leaves behind) every App Settings save got a 400 and no synced
setting reached the server again, while the toast still read "Settings
saved" (_apiPut resolves on a 400). Pre-existing, but #560 now points users
at the Shortcuts tab to bind Close Session again, so it would be hit often.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only the stepper half of #428 lands here. Its other half, the phone case
picker search, shipped separately in #488 (1.33.1) and is kept as it is on
master, so every file of this merge starts from ours and only the stepper
changes are ported onto it:
- index.html: the second `− 1 +` group (#shellCount) after Run Shell is gone.
- session-ui.js: incrementShellCount/decrementShellCount are removed and
runShell() reads the toolbar's one stepper through _readTabCount(), the
helper master grew since #428 was opened (same clamp and absent-element
fallback as #428's _toolbarInstanceCount()).
- run-mode-ui tests stub #tabCount instead of #shellCount, plus #428's
"toolbar instance count" block adapted to _readTabCount() and a markup check.
- wiki: "the instance counter", singular.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec's as-built list, the wiki's Tile Grid page and the CLAUDE.md
tile grid paragraph: the button has no native title, its hover card
says the count and what a click and a right-click do, it is the
button's aria-describedby (always present, hidden, kept current), and it
hides in the capture phase on any press, click or right-click so the
count menu never opens beside it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found live: a click on Tiles hides the card and keeps it hidden while
the pointer rests on the button, until the pointer leaves. With the
pointer left there, tabbing away and back onto the button showed no card
either, so a keyboard user whose mouse happened to sit on the button
never got the Shift+F10 hint.
Leaving the button (blur) now ends that suppression as well: a keyboard
focus that comes back later is a new arrival. A click that opens the
grid or a right-click that opens the menu still shows nothing, since no
pointerenter or focus follows while the pointer stays.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback 1: "give me the hover info to right click over the tile
button to adjust it". The only hint was the native title, which the
browser shows late, small and unstyled.
The Tiles button now has its own hover card, under it in the count
menu's panel style:
- "Tiles · N" with the remembered count (live: a pick in the menu
changes it while the card is up), "Click: open the grid" ("close the
grid" while it is open), "Right-click: choose 2, 4 or 6 tiles", and
when the count does not fit the window what opens instead ("This
window fits 4 tiles: a click opens 4"; open, what fits). Shown from
the keyboard it adds "Shift+F10: the same menu from the keyboard".
- Shows 300 ms after a pointer that hovers rests on the button, or after
a keyboard focus (:focus-visible); never for a touch pointer or a
device that cannot hover (plus a CSS @media (hover: none) backstop),
never on a hidden button, never while the count menu is open. A short
fade on opacity and transform, none under reduced motion; it takes no
pointer.
- Hides on pointer leave, blur, Escape, a scroll and a resize, and on a
press, a click or a right-click on the button (capture phase, so it is
gone before the menu or the grid opens, and it stays gone while the
pointer rests there). openTileCountMenu hides it too, and the focus
the menu's Escape puts back on the button brings no card back.
- It replaces the button's native title (two tooltips never stack); the
aria-label stays and aria-describedby points at the card, which always
exists and is kept current, its keyboard line included while hidden, so
screen readers hear the same text without a hover. Text is diffed
against the last English, as the rest of the grid chrome, so the zh-CN
translator is not fought on every refresh.
- zh-CN for every line (平铺 · N, 单击, 右键单击, Shift+F10, the fits
note); no other header button changes (its styles are .tile-hint only).
Tests: tile-grid-hint.test.ts (install and aria wiring, the delay, each
show and hide path, content per state and count, the menu rule, the
translator, the CSS); the count menu and i18n tests read the accessible
name instead of the removed title, and the i18n harvest covers the card
(F10 joins the key names that stay Latin). The vm harness gains
removeAttribute.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ensureAwake() hands the readiness poll the caller's budget minus the wake step's own elapsed time, so on a busy runner the poll gets 39999 ms and the two tests that expected exactly REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS flaked (seen in CI on #550 and in a local gate run). Both now check the value sits within a second under the budget, the shape the host-scoped wake test already uses, which still fails if the 90 s session default leaks through.
_syncCliLaunchCatalog() rebuilt window.__codemanCliCatalog from /api/clis rows, which carry no capabilities, so after App Settings loaded the CLI list isExternalCliSession('claude') fell back to the kind check and Respawn and Ralph disappeared again until a reload. The rebuild now carries external over from the served catalog; a newly created custom CLI has no previous entry and falls back to kind, which is right since custom entries are external. Tests pin the resync and the openSessionOptions() call site, and the /api/clis comment names the page catalog as the deliberate capabilities exception.
Four ports were shared by two files each — 3162 (qr-auth / auth-security), 3170
(multiuser-auth / routes/ws-routes), 3230 and 3231 (cod54-hook-event-auth /
routes/voice-routes). Files run serially (`fileParallelism: false`), so the pairs
never met inside one run; they collide between two runs on one host, or with
anything else holding the port. All six files now bind port 0 and read the
number back (`boundPort` for WebServer, `server.address()` after each listen for
the raw Fastify / ws servers).
test/test-ports-guard.test.ts fails on a WebServer built under test/ whose port
argument is not the literal 0 — `new WebServer(…)`, a subclass, or a destructured
alias (`{ WebServer: T }`, as quick-start.test.ts does) — outside a legacy list of
the 43 files that construct one with a non-zero port today; the follow-up sweep
converts them. A converted file cannot stay listed. What it does not cover (helper
parameters, `import { WebServer as X }`, raw listen sites) is written down in it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`port: 0` already bound an ephemeral port at the socket level, but `this.port`
stayed 0: the banner printed `:0`, CODEMAN_API_URL pointed panes at `:0`, the
docker bridge listener and the unauthenticated-bind warnings read 0, and the
route context's `port` was a by-value snapshot taken in setupRoutes(), before
listen() runs, so `tunnelManager.start(ctx.port, …)` would have been handed 0.
- After `app.listen()`, `this.port` takes the number from
`this.app.server.address()` (string/null addresses are left alone).
- The route context exposes `port` as a getter, and the cron routes get only
the `cron` their CronPort declares instead of a spread copy of the context.
- `get boundPort()`: the readonly accessor tests use instead of a private field.
test/webserver-bound-port.test.ts compares each reader against the socket's own
`address().port`; all three tests fail with only the write-back removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
opencode's TUI enables mouse tracking. tmux runs with `mouse off`, so it passes
the PANE's DECSETs straight through to the tmux client, and the browser's xterm
obeyed them: `mouseTrackingMode` flipped to 'any' (measured 62 none / 18 any over
16s) and xterm then reported DRAGS to the TUI instead of selecting locally.
In that state marking text produced no selection at all, so copy-on-select
silently did nothing (5/5 dead drags while `any`), and the obvious fallback —
Ctrl+C — is opencode's `app_exit`, which ended the session. Both were hit here.
opencode needs the middle strip: alt-screen toggles AND mouse DECSETs, but NOT
`3J` (a TUI is not a `clear` consumer). That is `altScreen: 'strip-mux-and-mouse'`
+ `isMuxMouseStripMode`, applied to the live stream (session.ts) and the replay
of a stored buffer, now the exported `stripReplayBuffer()` (session-routes.ts).
The browser's mouse-report gate keeps no mode list any more:
`_shouldReportMouseToCli()` reads only `cliMouseTracking`. The server sets that
flag solely in the mouse-strip branch (`_recordStrippedMouseMode`, one caller),
so it can only be true for a mode whose DECSETs are stripped, and whichever modes
the registry strips, the browser follows. Clicks still reach opencode through the
hand-encoded SGR tap it gates.
The `altScreen` JSDoc gets the decision table its three independent choices need
(alt-screen / `3J` / mouse DECSETs), written from the predicates, including that
`preserve` and `strip-mux-only` take the same runtime row. The table is pinned for
every stock CLI, with and without tmux, on both the live strip and the replay
strip, plus the published flag (test/claude-scrollback-strip.test.ts), so the two
halves cannot drift and a mis-ordered replay branch fails.
Docs and comments that still said opencode keeps its mouse reporting or gets the
narrow strip are updated (CLAUDE.md, architecture-invariants, scrollback and
copy-shortcut plans, session.ts, terminal-ui.js).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
opencode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
`alternate_on=1`, `history_size=0`), so tmux keeps no history for the pane and
the browser's normal buffer never grows past one screen (`baseY === 0`). The
plain wheel therefore scrolled a buffer with nothing in it — dead in every
opencode tab, on desktop and touch alike.
opencode is not a forwarding candidate: it IGNORES SGR wheel reports (six
`\x1b[<64;…M` reports against an idle pane left the capture byte-identical),
but it does page its own transcript on PageUp/PageDown (`messages_page_up/down`,
verified on the same pane). The hollow-buffer rescue already sends exactly those
keys — it was just gated to `claude`. Widen the gate to opencode so the wheel
and touch gestures reach the CLI's own transcript instead of a no-op.
Every other mode stays out: shell/pi own real terminal scrollback, and
codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified
(docs/scrollback-fix-plan.md).
Test: test/terminal-scroll-routing.test.ts — new opencode case (Red before the
fix, Green after); the "real local scrollback is untouched" case now also pins
antigravity as not-paged.
The wiki's Contributing page still warned against bare npm test and pointed at test:ci, from before 947ff6f6 made npm test the CI gate and gave the browser, mobile and perf suites their own runners. It now matches .github/CONTRIBUTING.md and CLAUDE.md.
The tunnel Upload URL page (upload.html) has been broken since the response envelope landed in 458fb81c: it reads j.filename and j.files while the server answers { success, data: { filename } } and { success, data: { files } }, so every upload reported "Saved: undefined" and the recent list stayed empty. Nothing else reads ~/.codeman/screenshots/, and handing a file to an agent goes through POST /api/sessions/:id/paste-image into the session's own workspace, so the page, its Settings row, the suffix branch of the tunnel row helper (now folded into its one caller) and the Upload URL i18n key go.
The three /api/screenshots routes keep working unchanged and log one deprecation warning per process on first use, naming paste-image as the replacement. Per docs/versioning-policy.md they are removed in a later MAJOR, after at least one MINOR release that carries the warning; the docs, CLAUDE.md and the multi-user plan say so.
Static caching: with upload.html gone no HTML is served by @fastify/static any more (every page has its own no-cache route; on a built tree only the precompressed index.html.gz artifact is reachable, as application/gzip, and nothing requests it). The .html branch of setHeaders was therefore dead and goes with the test that fetched upload.html to reach it; a comment now says a new static HTML page needs its own route. The index.html no-cache assertion on the route stays.
Since the CLI registry gave claude kind 'agent' (#476), isExternalCliRunMode() reads claude as an external CLI, so Session Options opened every Claude session on Summary and hid the Respawn and Ralph tabs and every Claude-only control (auto-resume, the respawn loop). The browser catalog now carries the registry's capabilities.external, the flag the server's isExternalCliMode() already reads, and Session Options asks that instead. run() keeps isExternalCliRunMode(): choosing a launch path is a different question, and custom agents rely on it.
A global `.btn-icon-header:hover { transform: rotate(45deg) }`, meant for
the settings gear, turned every header icon button on hover, so the folder,
Tiles, Split and the rest swung their rounded hover background into a
diamond. Three buttons had already cancelled it one by one (the font-size
buttons, notifications, the sidebar toggle).
The rule is gone, and with it those three overrides. Hover motion now moves
the icon only:
- the settings gear's icon turns 45 degrees (one tooth, so it lands on the
same shape);
- the Tiles button's four squares spread apart, each toward its corner;
- the folder cross-fades to an open folder (a second drawing in its SVG,
`.icon-folder-closed` / `.icon-folder-open`);
- every other icon just takes the hover colour.
Pointer devices only (`@media (hover: hover)`, so a tap cannot leave an icon
stuck mid-motion), and the transitions are off under reduced motion.
Owner request: the Tiles and folder buttons "weirdly turn" on hover.
Checked live on a dark and a light skin (rest, mid, end frames). Pinned by
test/header-icon-hover.test.ts, mutation-checked five ways (the button
rotation back, the open drawing missing, the motion not hover-gated, a
square spreading toward the wrong corner, reduced motion keeping its
transition). Gate: 507 files, 9798 tests passed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found live: closing the grid with a click starts the single view's
selection, which focuses its terminal when its replay lands, a few
hundred milliseconds later. A right-click on Tiles in between opened the
count menu with the keyboard in it, and the late focus then moved the
keyboard into the terminal while the menu stayed open (3 of 3 tries), so
the arrows, Enter or Escape meant for the menu went to the session's
PTY instead (an Escape arrived there as an ESC byte).
The menu now closes when the keyboard leaves it for another element, as
any menu does. A focus going nowhere (a click on a button in Safari,
which does not focus it) does not count, so a click on a count still
picks it. Live afterwards: the menu either closes as the terminal takes
the keyboard, or keeps it when the replay landed first; never open with
the keyboard elsewhere.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- docs/tile-grid-plan.md: owner decision 10 (right-click Tiles is a
2 / 4 / 6 count menu, default 6, remembered per device; the session
picker is gone; decision 8's "picker on right-click" and "exactly the
stored set" superseded) with the owner's answers on the details; three
as-built bullets (the count menu, the animation, painting first); the
Tiles-button bullet rewritten for the count; the entry points, the
capacity note, the multi-user row and the Escape invariant follow.
- docs/architecture-invariants.md: the Opening paragraph rewritten (the
count, the stored grid in its cells, the menu owning its Escape, the
paced connect and focusOnConnect, the motion rules); the z-index list
names the count menu and the closing grid's still copy.
- CLAUDE.md: the tile grid paragraph and the z-index line.
- Wiki: Tile Grid (the click, the count menu, the animation, reduced
motion) and Keyboard Shortcuts (right-click Tiles).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner answer: "paced connect: in". Measured on the checkpoint build: of
the 140 ms between the click on Tiles and its first frame, about 100 ms
was the six xterms being built inside the click, so nothing moved on
screen for that long and the entrance could not start.
openTileGrid now mounts every tile and lays the grid out as before, then
_connectTilesPaced builds one terminal per animation frame, the focused
tile's first, then reading order. The click paints its empty tiles in
about 20 ms and the entrance plays while the terminals are built. The
time until every tile has painted does not change: the load queue serves
one capture at a time, so only the focused tile's connect is on its path,
one frame later. Each tile still connects once, into its final cell (one
fit, one PTY resize).
- openTileGrid returns before the terminals exist, so a selection that
focuses a tile whose terminal is not built yet hands the keyboard over
in _connectTile (focusOnConnect), never when focus: false was asked,
and to the newly focused tile when focus moved meanwhile.
- _connectTile connects a tile once (entry.connected; a remount after
Attach resets it), so a re-form or a remount before a tile's turn is
never connected twice.
- A grid closed or opened again meanwhile stops the old run (a run
token), and asks for no further frames.
Tests: tile-grid-paced-connect.test.ts (the order, the final cells, the
keyboard, close and reopen, removed and remounted tiles); the tests that
read connect or the terminal's focus right after openTileGrid now run
the queued frames first (flushFrames in the vm harness), every
assertion kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: "when clicking on the tile button first make this
animation nicer". Recorded before: the click froze the page, six empty
tiles cut in at once, each tile's history then scrolled in visibly as
its load landed, and closing showed an empty single view for about a
quarter of a second before its own replay scrolled in.
The grid's own motion, on by default (not an entrance-animations.js
theme, which are off by default):
- Opening: each tile fades and settles in (opacity, translateY 6px,
scale .97), 180 ms, 24 ms apart in reading order: the last of six is
done at 300 ms. Its terminal stays transparent until the load queue
reports its first capture done, then fades in whole (160 ms), so no
replay scrolls by; a 15 s backstop shows it should that never come. A
tile added later enters the same way.
- Closing with the toggle (button, Ctrl+Shift+G; owner answer: only
these): the close stays synchronous, and a still copy of the tiles
(clones: no xterm, socket or listener; inert, aria-hidden, no pointer)
dims at once over the stage, holds until the single view's
selectSession has replayed its session (at most 700 ms), then fades
out. No empty single view between the two. A reopen drops a copy
still showing; a web tab hides it.
- A re-form to another count fades the old grid's copy out at once while
the new tiles enter.
- The count menu fades in (140 ms).
Every one animates opacity and transform only, so FitAddon measures the
final cell and each tile still sends one PTY resize (#464); nothing
moves, and no copy is made, under prefers-reduced-motion.
Tests (tile-grid-motion.test.ts): the stagger, the reveal and its
backstop, the same fits and connects with and without motion, the held
copy (released on settle, by its cap, removed on the last tile-leave or
its fallback), only the toggle animates, a reopen purges, the zoomed
tile alone, the re-form, reduced motion, and a CSS guard that every new
keyframe touches only opacity and transform. The vm harness gains
style.setProperty, cloneNode, isConnected and lastElementChild.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).
- A pick is remembered per device in codeman:tile-count (default 6;
codeman:tile-grid stays ids only) and opens that many tiles. The click
and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
last had, else an open split's two, else tab order, the active one
included and focused), trimmed from the end (the focused one kept) or
filled from tab order to the count. A remembered grid comes back with
its tiles first, in their cells, holes filled first, then tab order
(owner answer, superseding decision 8's "exactly the stored set"). A
page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
change under the cell model's rule (reformTileCells), the focused tile
always kept, every joining tile mounted and laid out before any of
them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
remembered one stays checked, the keyboard starts on the largest that
fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
(the global handler gives it the key first, like the tab-group menu)
and puts the keyboard back on the Tiles button; Tab and a click
elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
modal row); the i18n test harvests the menu now, with a session named
"6 tiles" as the user-text trap.
Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Tiles button's right-click becomes a count menu (owner decision 10):
how many tiles, 2, 4 or 6, default 6, remembered per device. These are
its pure parts in constants.js (window.CodemanTileGrid):
- TILE_GRID_COUNTS / TILE_GRID_COUNT_DEFAULT and sanitizeTileCount: a
remembered count is one of 2, 4, 6, anything else reads as 6.
- tileGridSetForCount: what the grid opens (or an open grid shows)
trimmed or filled to N: trimmed from the end with the session to focus
always kept, filled from the open sessions in tab order; fewer
sessions than N give fewer tiles; never past the cap.
- tileCellCols: the column count a stored cell list was laid out with
(stored cells carry no shape of their own).
- reformTileCells: a count change is a shape change: the tiles that
stay keep their cells, the cell model's rule (fitTileCells) reshapes,
and the tiles that join fill the empty cells in reading order, holes
first.
Nothing uses them yet; the menu comes next.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback 1: "so the empty tab doesnt always have to be the last
one! so I can move freely around and the empty tab can also be tab nr
4 or 3". This replaces the slot refusal of aecada8c.
grid.cells (a session id or null per cell) is now the one source of
truth; grid.ids is a getter deriving the tiles in reading order, so
everything that only wants the tiled sessions (focus neighbour,
cycling, the load queue's order, the picker, closeSession) is
unchanged. The shape still comes from the tile count and the cap
counts tiles, never empty cells.
- A tile dragged onto an empty cell moves there and leaves its own
cell empty, nothing else moving (_moveTileToCell, through
_reorderTiles: no remount, reconnect or reload; only a tile whose
cell size changed fits). A tiled session's tab does the same; a tab
of a session not tiled yet joins in the cell it is dropped on. Each
slot knows its cell and reads "Drop a tab or a tile here".
- Move Tile goes to the adjacent cell: into it when empty, a swap when
a tile is there (tileCellInDirection).
- Removing a tile leaves its cell empty; adding one takes the first
empty cell. A shape change goes through fitTileCells: each tile keeps
its row and column when all fit (2x2 growing to 3x2), else the tiles
pack in reading order.
- Focus never lands on an empty cell: Alt+Shift+Arrows run over the
cells, Ctrl+Tab and Alt+[ ] over the tiles.
- codeman:tile-grid stays ids only: its ids are the cells with null for
an empty one. A reload brings the holes back when the shape is the
same (a session gone since leaves its cell empty), another shape
packs, the old packed format reads unchanged, and a followed
#session= link keeps the holes.
Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile
Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner: an empty cell need not be the last one ("the empty tab can also
be tab nr 4 or 3"). The helpers that let the grid hold cells instead of
a packed list, with no behaviour change on their own:
- fitTileCells: the cells after a shape change. The same shape keeps
every cell, holes included; a new shape keeps each tile at its row
and column when all fit (2x2 growing to 3x2: the four tiles stay
put), else the tiles pack in reading order.
- tileInDirection takes cells: focus never lands on an empty cell.
Left and right go along the row past a hole and never leave it; up
and down take the nearest row with a tile, the same column else the
nearest (lower on a tie). For a packed list this is exactly the old
rule, short last row included.
- tileCellInDirection: the adjacent cell a Move Tile chord moves into
or swaps with.
- sanitizeTileGridState reads the stored ids as cells (null for an
empty one) and returns them as `cells` beside the packed `ids`; a
dropped id becomes a hole, never a shift. The old packed format reads
as cells with no hole.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The owner's answers on moving tiles:
- "dont move the tile": an empty slot no longer takes a tile. A slot is
always the last cell, so a move there shifted every tile after it.
Each drop target now says what it accepts (_acceptTabDrops'
`accepts`): a tile takes any session but its own, an empty slot only a
session not tiled yet. A refused drag is still held (dropEffect none,
no highlight), and dropSessionOnSlot refuses a tiled session too, its
tab included. A session not tiled yet still joins on a slot.
- A cancelled drag changes nothing, focus included (best practice): the
header focuses its tile on click, never on press, so a drag that ends
with Escape or outside leaves focus and the idle alert alone. The body
keeps press-to-focus, so focus still moves before a press reaches
xterm. The rename input stops its own clicks.
- A tiled tab dropped on the zoomed tile stays refused (confirmed).
- The Alt+Shift+Arrow focus chords skip a text field too (best
practice), as the move chords already did: shifted arrows select
there. Toggle and zoom are not text-editing keys and are unchanged.
Docs: the wiki, the spec's as-built bullet (with the owner's answers),
the invariants and CLAUDE.md say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki's Tile Grid page gets a "Moving tiles" section and the move
chords in its keys table (with what they leave to a text field and
take from a terminal editor inside a tile); Keyboard Shortcuts lists
the chords and the header drag. The spec records moving as an owner
request in its as-built list, with the reasoning behind the default
keys. The invariants and CLAUDE.md say every move goes through
_reorderTiles (no remount, reconnect or reload; only a tile whose cell
size changed fits) and that the header drag carries its own type and
is not draggedTabId.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Four rebindable registry chords in the Tiles group move the focused
tile: it trades places with the neighbour the Alt+Shift+Arrow focus
chords pick (tileInDirection), through the same _reorderTiles path as
the header drag, and keeps the focus. Nothing at an edge.
They go through tileShortcutFor()/runTileShortcut() like the other
tile chords, so every xterm key handler swallows them while they
apply: only while the grid is open, a zoomed grid included (a no-op
there, so the keys never reach the CLI), and never in a text field
other than xterm's own textarea, where Ctrl+Shift+Arrows select by
word.
Ctrl+Shift+Arrows because every other two-modifier arrow chord is
taken: Ctrl+Alt switches workspaces (GNOME, Xfce, some Windows
graphics drivers), Ctrl+Alt+Shift moves a window to another workspace
(GNOME, Cinnamon, Xfce), Super belongs to the desktop, Alt is the
browser's back and forward, Alt+Shift focuses tiles. No browser,
GNOME, KDE, macOS or Claude Code default uses Ctrl+Shift+Arrows; it
costs a terminal editor's word selection inside a tile while the grid
is open.
The Help modal lists the chords and the header drag; the shortcut
overlay lists the registry. zh-CN entries for every new string.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: "give me the option to move the tiles around". A tile's
header (its free area, not the buttons or the rename input) is now a
native drag handle: dropped on another tile the two trade places, dropped
on an empty slot it moves there (the last cell; the tiles after it close
up). Escape or a drop anywhere else is the browser's own cancel and
moves nothing.
Every move goes through one path, _reorderTiles: the header drag, a
tab of a tiled session dropped on a tile or a slot, and (next commit)
the Move Tile chords. Nothing is remounted, reconnected or reloaded.
Divider sizes belong to the cells, so a moved tile takes its new
cell's size: each tile whose cell size changed fits once (one PTY
resize, #464) and every other tile is left alone, in place of the
debounced refit of every tile the swap used to schedule.
The drag reuses the tab drop targets (capture phase, stopped before
xterm), carries a type of its own and never text, and is not
draggedTabId, so neither a text field nor the tab strip takes it.
Moving is off while a tile is zoomed (draggable off, and a tab drag
of a tiled session onto the zoomed tile is refused too) and with a
single tile. The handle shows a grab cursor and says it drags in its
tooltip (zh-CN included); the dragged tile is dimmed and the target
highlighted.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The digit rule from 21ae48a5 hid the official DeepSeek ids (`deepseek-chat`,
`deepseek-reasoner` carry no digit), so a session on the official route with
the model field on showed the logo alone (its bundle row pins a provider
alone, so the config had nothing either). It also still misread a folder name
with a digit when every field before it was off.
Now the captured field is rejected when it is what the field can be when it is
NOT the model, and read otherwise:
- capabilities.modelDetect.rejectWords (registry data, single tokens, compared
ignoring case; the schema bounds them and requires a screenLine). dsh lists
every effort id its adapters offer (pi-ai THINKING_LEVELS plus the DeepSeek
adapter's off/low/high/max) and the shipped mode ids, from dsh 0.1.1-rc.2 /
dsh-TUI 0.10.0-beta.1. A mode's drawn label (`plan mode`, `full access`,
CJK) can never be one captured field.
- In the shared screen reader, for every CLI: a field equal to the session's
own working-directory basename is the folder, never the model.
Fixtures: `deepseek-chat` and `deepseek-reasoner` with the model field on are
read; every effort id, `default`, `plan mode`, and the folder name first (with
and without a digit) are not; the live qwen footer still reads `qwen3.8-27b`.
Known gaps, all off by default, are named in stock.ts: a custom mode id drawn
raw, a git branch or a one-word session title first, and the non-compact
footer layout (nothing read there; the route config applies).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- api-reference: the `config` source in the displayModel table, read again at
every pane start, attach and relaunch rather than restored.
- cli-registry: `modelDetect.configResolver` (a named, read-only, bounded
reader), the stock `deepseek-route` reader and its rules, and why the dsh
footer pattern needs a digit.
- deepseek-integration §4: Codeman reads the route for display only, the way
dsh-TUI resolves it; the catalog check it cannot see.
- architecture-invariants (tile grid), tile-grid-plan "as built" (owner
feedback 1), the wiki's model row, CLAUDE.md's source order.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session header's tooltip (tile and split) says where a model the CLI did
not report came from; the new `config` source reads
"DeepSeek · qwen3.8-27b (from config)", with the zh-CN pattern
"(来自配置)" (the harness and model names pass through). The header's model
text itself is unchanged. Covered in the chrome tooltip test and the i18n
harvester's exercise.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dsh-TUI draws the model as its status line's first field only while the
status bar's model field is on (the default). Switched off, the first field
is the reasoning effort (` medium · th-scratch`), else the mode, else the
cwd's basename, and the footer pattern read that as the model, which would
also outrank the route config added in the previous commit.
The captured field must now carry a digit, as a model id does (a version) and
an effort word, a mode name or most folder names do not. A model id without
one (`deepseek-chat`) is not read from the screen and the session falls back
to its route config: silent, never wrong.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
displayModel gains a `config` source, ranked below any report from the running
CLI and above the launch model: custom endpoint, then statusline or screen,
then config, then launch, then nothing. The screen still wins whenever it
names a model, since that is what the running TUI uses.
- Registry data: capabilities.modelDetect gains `configResolver`, a NAMED
reader (src/model-config-resolvers.ts), like a launcher profile; dsh names
'deepseek-route' (the reader from the previous commit). `screenLine` becomes
optional; the schema refuses a modelDetect naming nothing, an unknown
reader, or screenLines without a screenLine.
- Session: the reader runs from _withPaneLifecycle's finally, so at every pane
start, attach and relaunch, with the session's own launch config
(legacyConfigForMode) and env (its clamped overrides, then the server's), so
a per-session DSH_HOME is the home read. Async; a read that lands after a
newer one or after the session stopped is dropped; a remote or docker
session reads nothing locally. A change emits displayModelChanged
(broadcast and persist). Not restored after a restart: the next attach
reads it again, and a restored screen value outranks it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A DeepSeek session whose screen names no model (dsh-TUI's status bar model
field off, or not drawn yet) can still name the model its route config pins
(owner request). src/deepseek-route-config.ts resolves it the way dsh and
dsh-TUI 0.10.0-beta.1 do, for the session's profile (else the one the launch
boots) under the session's dsh home:
- dsh composes a profile from patch layers: the bundles, then
profiles/<profile>/cordis.patch.yml, then $DSH_HOME/cordis.patch.yml (which
outranks it). A patch's `config` replaces the dsh-tui row's whole config, a
`name` mismatch skips it, `disabled` turns the row off.
- dsh-TUI takes its route from that config only when it names BOTH provider and
model (modelRoute.js); anything less falls back to state the config does not
hold, so the answer is nothing. The bundle row pins a provider alone by
design and is not read (it resolves outside the dsh home); settings.yaml's
agent-default-model is the headless default and is never read.
- Any doubt answers nothing: a profile without dsh-TUI, a half-pinned route,
an unreadable or oversized layer, a symlink out of the dsh home, a mount that
does not answer, a file beyond a narrow strict YAML subset (no dependency
added: plain keys, single-line string scalars for the values it needs; tags,
anchors, aliases, merge keys, multi-line scalars, flow or block-scalar
config, duplicate keys, typed scalars, a second document, or a nested row
re-defining dsh-tui all answer null).
- Bounded and read-only: every path is probed with probePathKind() first, read
async with a 64 KiB cap, and must realpath inside the dsh home. Only the
model id leaves the module.
The default-profile inventory reuses the resolver's classification through a
new pure deepSeekProfileFromManifest(), read with the same bounded rules.
Not wired to sessions yet; the next commit does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- api-reference: the new `displayModel` session field, its sources in order
(custom-endpoint, statusline, screen, launch) and that it is untrusted
display text, persisted and restored when the CLI reported it.
- cli-registry: `capabilities.modelDetect` (one capture group, the last rows
of the probe's capture, anchored on chrome only that CLI draws), the two
stock patterns (dsh-TUI, codex) and the fifth config regex.
- architecture-invariants (tile grid): the header painter, the id as data, the
untrusted model text, no writes for an unchanged session, the truncation
order, Pane A's strip and its fits through syncTerminalGeometry.
- tile-grid-plan "as built", the wiki's Tile Grid page (logo and model rows,
Split's strips), and CLAUDE.md's tile grid and CLI registry paragraphs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Each session in the split view" (owner request) gets the tile header's
strip: the harness logo, the name and the model, painted by the same
_paintSessionHarness.
- Pane B's header is built from nodes now (it was innerHTML with the name
escaped) and follows renames and model changes on every tab render, like a
tile header; its close button is a tile button (26px target, 19px glyph).
- Pane A is the main terminal, which has no header of its own: while the
split is open it gets the same strip, minus the close, as the first child of
.terminal-wrap, and it names the active session. The strip takes 28px from
the main terminal, so the opening resize fits it with the strip already in
place, and closing removes the strip before giving the height back. Both go
through sendResize / syncTerminalGeometry (#464): the close no longer calls
a bare fitAddon.fit(), and a close that skips the server resize (Pane A's
session ended) still refits through syncTerminalGeometry.
- The partial-history banner, which overlays the top of .terminal-wrap,
starts below Pane A's strip while it is there.
test/split-pane-headers.test.ts drives the real split code on the grid's vm
harness: both headers, text-only names and models, refresh on a tab render,
no writes for an unchanged session, and the opening/closing fits.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tile header is now `● [logo] name · model ..... ⋯ ⤢ ×` (owner request):
- The logo is PR #532's `run-mode-dot <cliId>` slot, so the logos, the skins
and the plain dot of an id without a logo stay single-sourced in styles.css.
The id is data (a class and a catalog lookup), never a branch; the frontend
id-branching guard now also scans constants.js, terminal-split.js and
tile-grid.js (the one existing shell branch in tile-grid.js, the attach
route, is allowlisted with its reason).
- The model is the session's displayModel, as text in a data-i18n-skip span
inside a box whose tooltip may translate. Unknown means the logo alone.
- The logo's tooltip and accessible name say "<harness> · <model>", plus where
a model the CLI did not report came from ("set at launch", "custom
endpoint"; zh-CN patterns for both, the names pass through). The model's box
is aria-hidden so a screen reader hears the model once.
- One painter, _paintSessionHarness (terminal-split.js, shared with the split
panes next), diffs against what it last wrote, never the DOM: an unchanged
session writes nothing on a tab render.
- On a narrow header the model gives way first, then the name: the name does
not shrink at all and is capped at its box, since any shrink factor takes a
subpixel from a name that fits and ellipsizes it.
The chrome and zoom tests found header parts by child position; they now look
them up by class, with every assertion kept (the rename tests had been passing
against the new logo node by position). The i18n harvester files the logo's
labels as harness and model names that must stay as they are.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session header can only name the model a session runs if the server knows
it, so SessionState gains `displayModel: { model, source }`, resolved in a pure
module (src/session-display-model.ts), strongest first:
- custom-endpoint: a Custom Model Endpoint Profile's modelId answers the
session, whatever alias the CLI prints;
- statusline / screen: the newest report from the running CLI itself.
Claude's statusLine exporter already posts model.display_name on every
render; the status-telemetry route now records it (only for a CLI with
capabilities.statusLineTelemetry). A CLI whose registry entry declares the
new capabilities.modelDetect has its footer read off the pane capture the
idle/working probe already takes (no extra tmux call), so an in-session
/model switch is followed at the next transition;
- launch: the model the session was launched with (claude's --model or the
app-wide default, another CLI's <cli>Config.model), read where the registry
says the model param lives;
- nothing known: no field, never a placeholder.
modelDetect is registry data, measured on live panes: dsh-TUI's status line
on the row under its composer (qwen3.8-27b on the owner's route) and codex's
`<model> <effort> ·` footer on its last row. Both anchor on chrome only that
CLI draws, over the last rows of the screen only; a transcript line shaped like
the footer is never taken (fixture tests). The pattern goes through
compileVersionRegex() with exactly one capture group, checked at load time.
An unreadable or covered footer keeps the last model (unlike the watching
label: a model does not stop running when something covers its row). Model
text is untrusted: escape sequences and control characters are stripped and it
is capped at 64 characters. A change emits displayModelChanged, broadcast
(session:updated) and persisted; a restart restores a CLI-reported model until
the next report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Run menus (toolbar dropdown, phone overview picker, Custom Endpoint rows,
model picker) marked every backend with an 8px colour dot, so telling Codex
from DeepSeek meant reading the label. Each known backend now draws its own
logo in that slot. It is CSS only: every surface already renders
`.run-mode-dot <id>`, so no markup changes.
- Brand-coloured marks (Claude, Gemini, Antigravity, DeepSeek, OMP) paint as a
background image; monochrome ones (Codex, OpenCode, Pi, Grok, plus Shell and
web URLs) are masks over the row's text colour, so they follow every skin.
- Logos are inline SVG data URIs (img-src already allows data:), from
@lobehub/icons-static-svg 1.95.1 (MIT); the OMP mark is omp.sh's own.
- Drops the non-og skin overrides that re-tinted four dots with a
`background:` shorthand, which would have wiped the logo.
- An id with no logo (a clis.json addition) keeps a dot, now in --text-dim
instead of being transparent.
- test/run-menu-cli-logos.test.ts pins that every stock agent plus shell/web
has a logo in exactly one paint group and that nothing resets the slot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d00229ee29)
tile-grid-load-queue carried its own FakeSocket, FakeFit and FakeTerminal,
near-copies of terminal-tile-input's. It now imports
test/mocks/terminal-tile-fakes.ts, which gains what only it used:
FakeSocket.drop(code), the terminal's scrollToLine / scrollToTop, and the
replay-pace extension (an opt-in `holdParse` that keeps write callbacks
from running, as on a disposed xterm, and empty writes left out of
`writes`, since the replay queues one only to hear it was parsed).
One definition serves both files with no per-file switch:
terminal-tile-input passes unchanged with the extension in place, the
shared fit resizes to the default 80x24 the tile already has, and
FakeSocket.OPEN is the real value. Every assertion is unchanged.
Mutation-checked through the shared fakes: dropping destroy()'s replay
settle fails the destroy-while-parsing case, a queue that runs two loads
at once fails eleven cases, and a tile that never registers its input
socket fails six in terminal-tile-input.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
terminal-tile-input defined FakeSocket, FakeFit and FakeTerminal inline;
they move unchanged to test/mocks/terminal-tile-fakes.ts so the grid's
load-queue test can drive a real TerminalTile on the same fakes instead of
its own near-copies. No assertion changed (a tile that never registers its
input socket still fails six cases).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_sseFilterSessionId() and its doc comment landed between
_updateSseSubscription()'s doc comment and that function, so two doc
blocks sat back to back and _updateSseSubscription had none. Each block is
now above its own function; the moved one's em dash became a colon.
Comments only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
f9709c3a added a clause to removeTile's doc comment without rewrapping it,
leaving one line far past the file's width. Comment only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Five tile-grid tests defined the same `tileEl(id)` lookup and three more
inlined it; the harness (test/mocks/tile-grid-vm.ts) now exports it and
they import it. tile-grid-open-set built a second vm context just to read
constants.js, although the harness it already imports has loaded the same
file: it reads windowStub.CodemanTileGrid instead.
No assertion changed. Mutation-checked: tiles without their
data-session-id fail 34 tests across the seven files, and a broken
tileGridOpenSet fails the open-set cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rename from SplitTerminalPane carried comments over that the PR 1
changes made untrue:
- constants.js buildSplitPickerSessions said TerminalTile._sendResize has
no detached check (it stands aside like the primary pane) and that Pane
B sends no `seq` (its input rides the exactly-once queue). The
conclusions stay: a detached session's window owns its PTY size, and a
session with no PTY has a pane nothing feeds or reads.
- terminal-split.js said TerminalTile has no "dims unchanged" skip (it has
_lastSentDims), and its @loadorder still ended at respawn-ui.js rather
than tile-grid.js.
- terminal-tile.js still called every pane "Pane B", said a shell load
lands in "a 50000-line xterm" (the scrollback is an option now) and
that a TUI session always gets a full replay (with boundedLoad, grid
tiles get the bounded window), and told some reasons as history ("an earlier draft", "used
to", "It LOOKED intermittent"). Those now give the reason in the
present tense, and the comments touched lose their em dashes.
writeChunked's doc comment is left as it is: the perf work rewrites
that function and owns its comment.
Comments only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
terminal-tile-unit slices connect() out of terminal-tile.js up to
'async _loadBuffer()'. The load queue commit (fca7acd0) gave _loadBuffer a
`{ refresh }` parameter, so that anchor stopped matching, indexOf returned
-1 and the slice ran to the end of the file: every check in the test
passed against code outside connect(). The anchor is now
'async _loadBuffer(' and the test asserts both anchors resolve, so a rename
fails it instead of widening it (checked by putting the old anchor back).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_applyTileLayout carried a loop that set every tile's zoomed class and
its ⤢ button's pressed state and label. That loop is now _syncTileZoom,
beside _syncTileSlots and _syncTileDividers, which _applyTileLayout calls
the same way. Same order of writes, same last-English-label compare.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
openTilePicker set `position: fixed` inline, which .tile-picker-menu
already declares; the inline copy (carried over from the split picker,
whose menu has the same rule) is gone. The top/right offsets under the
Tiles button stay inline, since they are measured.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
.tile-attach-btn and .tile-picker-open repeated the same seven
declarations and the same :disabled opacity; they now share one rule, and
each keeps only what differs (the picker's narrower padding, the
in-flight attach's progress cursor). .tile.focused read --accent-color, an
alias of --accent, while every other grid rule reads --accent; it reads
--accent too.
Computed styles of 22 grid elements (headless Chromium, the default,
daylight-blue and og skins) are identical before and after.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`.tile-grid--zoomed .tile-divider` and `.tile-grid--zoomed .tile-slot` hid
elements that do not exist while a tile is zoomed: _applyTileLayout toggles
the zoomed class in the same pass that syncs zero dividers and zero slots,
and it is the only place either is created. Both rules are gone.
The dividers test asserted the CSS rule; it now asserts what the user sees
(no divider elements while zoomed, two again on restore), and
tile-grid-entry-points gains the same check for the empty slot of three
tiles in a 2x2. Both fail if the zoom stops removing them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_tileGridCapacityNow had one caller, _tileGridLimit, and only measured the
area (the grid section, or the single view the grid would replace) for it.
The measurement now sits in _tileGridLimit, whose doc comment says what is
measured.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
computeTileLayout and tileGridCapacity took minTileW / minTileH, defaulted
to TILE_MIN_W / TILE_MIN_H, and no caller or test ever passed them. The
parameters are gone and both read the constants; the spec's signature line
says so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nextSession and prevSession each carried the same grid branch (cycleTile
from the active session, a human selection, skip the tab walk). It is now
_cycleTileFocus(delta) in tile-grid.js, next to the other focus moves;
both call it optionally, so a page or harness without tile-grid.js walks
the tabs as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both put a new set of sessions on the grid in place of the open one: choose
the focus (the session in focus if the set holds it, else the first), close
an open grid forgotten, drop activeSessionId so re-parking snapshots nothing,
then open. They now call one _replaceTileGrid(ids), which carries the
reason for the order once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the grid already open, openGroupAsTiles closed it (which sets
activeSessionId to null, so re-parking does not snapshot the parked
terminal) and only then chose the focus, so the active session was never
"in the group" and the group's first session always took focus. The
picker's Open chooses before closing. Now both do: the focused session
keeps focus when the group holds it, otherwise the group's first session
gets it.
tile-grid-entry-points covers both cases, with the grid open and closed;
the open-grid case failed before this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
closeTileGrid, removeTile, dropSessionOnTile and _remountTile each spelled
out `grid.queue?.drop(tile); tile.destroy()`. They now call
_destroyTerminalTile(tile), which keeps that order (the waiting loads are
resolved and the loading state cleared before the tile goes) and says why
once. The tile's element stays the caller's to remove.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
removeTile took `auto` for the neighbour it focuses, but no caller ever
passed anything but true: a tile leaving is never a human picking its
neighbour. The option is gone (the refocus passes `auto: true` itself, and
the doc comment says so), and the four call sites that spelled out the
defaults (the header's ×, remove-tile, a stopped socket, a popped-out
session) are plain removeTile(id). `refocus: false` callers are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grid holds at most TILE_GRID_MAX (6) tiles, but the file header of
tile-grid.js, the comment over its section in index.html and the grid
block in styles.css still said 1 to 9. The tile header descriptions in
_buildTileHeader, .tile-header and the chrome test listed `⋯ ×` without
the zoom button, and the glyph-size rule still spoke of four glyphs and
the plus that owner decision 9 removed. Comments only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Tiles picker opens on right-click now. Three pieces only served the
left-click picker: stopPropagation on the opening event, the tick of delay
before the outside-click listener went in (so the opening click could not
close it), and the exception for clicks on the Tiles button. A right-click
fires no click event, and a left click on the button runs toggleTileGrid,
which closes the picker before the click reaches the document. The
preventDefault that keeps the browser menu away stays.
The outside-click close had no test: tile-grid-open-set now checks that a
click inside the picker leaves it open and one elsewhere closes it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TerminalTile carried its own copy of the smart-copy branch (clean the
selection with this session's gutter, copy, clear, toast). PR 1 gave
cleanedTerminalSelection and copyTerminalSelection a `{ terminal, sessionId }`
target for exactly this, and nothing passed it. The tile now calls both with
its own terminal and session, and its copy code is gone.
Two things change for a tile, both to the primary pane's rule: a clipboard
write that fails keeps the selection (nothing was copied, so it stays for a
retry) instead of clearing it, and focus returns to the tile's xterm after
the copy, which matters when the execCommand fallback focused a temporary
textarea. Pinned in terminal-tile-input with the write failing and
succeeding, and the no-selection Ctrl+C / Ctrl+Shift+C split.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Opening the grid runs _cleanupPreviousSession() once to park the main
terminal, and for a non-shell session that serialized the terminal
(1000 lines of scrollback) into the snapshot cache and up to 256 KB of
localStorage. Closing the grid drops the main terminal's snapshot of
every tiled id (stale by then), so when the parked session is itself a
tile, which it is unless the grid opens on a set without it, that copy
was always thrown away. openTileGrid now passes skipSnapshot in exactly
that case; a parked session that stays out of the grid (Open group as
tiles from another session) keeps its snapshot as before.
Measured: the same serialize on the main terminal's buffer costs 32 to
42 ms per grid open (n=6, 35 KB) plus the localStorage write; shells
never took one, so the A/B runs (shells) show no difference. Snapshot
serializes per grid open with a non-shell session active and tiled:
1 -> 0.
Tests: the grid passes skipSnapshot only when the parked session is
tiled; the real _cleanupPreviousSession skips the serialize only when
asked; mutation-checked both ways.
Scope: PR 2 (tile-grid.js, the app.js seam).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The invariants for this performance pass: a tile's replay holds the load
queue only while xterm parses it, and destroy() settles a replay in
progress; the main terminal's resize timer refits the split's Pane B
only, leaving grid tiles to the grid's observer; the page's SSE filter
names TILE_GRID_SSE_FILTER while tiles own the terminal; grid tiles send
lines= on their full captures. Plus an "As built" note in the spec, whose
parking section still says the subscription stays [activeSessionId].
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /api/sessions/:id/terminal?full=1 captured the whole tmux history
(capture-pane -S -<history limit>, 100,000 lines by default) and cut it to
`tail` only afterwards, all of it synchronous on the server's event loop.
A grid tile keeps TILE_SCROLLBACK lines plus its screen, so the rest was
captured to be thrown away, once per tile on every grid open, restore and
deploy reconnect. The route now takes an optional `lines=<n>` (an integer
of at least 1, clamped to the configured history limit) and passes it as
the capture's history bound (the existing historyLimitLines, so -S -<n>);
absent or malformed, the limit itself, so every existing caller gets the
same capture as before. Only full captures read it: the visible-frame path
(a shell tile's `tail=` load) reads no history and is untouched. The
capture still ends with its RELATIVE cursor move back to the caret, still
counts as a full capture (isFullCapture: the line-deleting transforms stay
off) and still reports captureCols/captureRows.
Grid tiles (boundedLoad) send lines=<scrollback + rows> on every full
capture of theirs: a TUI load and a shell history pull. The split's Pane B
asks for everything, as before.
Measured:
- A real haiku Claude pane on tileperf (about 3k lines of history):
bounded captures (lines=50, 500, 2000, 100000) against the unbounded
one, 4 PASS 0 FAIL: each a line-aligned suffix of it, ending in the
same relative cursor move (ESC[4A CR ESC[2C), same source
(mux-full-history) and capture geometry. Capture time there 72 ms both
ways, that history being shorter than the tile's bound. As a grid tile
(it sent lines=10047) its screen matched the pane row for row, 47 of
47 at the pane's own 77x47, caret on the composer.
- Six tiles restoring with Claude-style loads (full=1&tail=1MiB forced
on shells with about 19k lines of tmux history each, above the tile's
bound; n=3+3 interleaved, load 4.2 to 7.2): capture per tile med
219 ms [194 to 294] -> 155 ms [127 to 211]; server event-loop delay in
the capture window, max med 262 -> 201 ms; all painted 4.5 -> 3.6 s.
At checkpoint 1 a 30k-line history cost 713 ms per capture (event loop
blocked up to 765 ms each); the bound caps that at the tile's size.
Tests: the route passes lines= through, clamps it, ignores every malformed
form and leaves the visible-frame capture exactly as it was; a bounded
capture keeps its rows and ends in the cursor restore; grid tiles send it
on full captures and the split's Pane B does not. Mutation-checked six ways
(lines ignored, no clamp, a lenient parse, lines on the visible path, the
tile sending none, Pane B sending it). Documented in docs/api-reference.md
(/api/v1 is public).
Scope: PR 2 (the grid's loads; server route plus terminal-tile.js).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the grid open the page's SSE filter still named the focused tile's
session, so the server streamed that session's output over SSE as well.
The main terminal is parked (its socket closed, _wsReady false), so every
frame was JSON.parsed and then dropped by the park guard; the tile has the
same output over its own socket. The filter now names TILE_GRID_SSE_FILTER
(constants.js, a fixed id no session takes) while tiles own the terminal,
at both places that set it: the live re-subscribe every tile focus runs
(_updateSseSubscription) and the connect URL an SSE reconnect rebuilds
(connectSSE), through one helper, _sseFilterSessionId(). Leaving the grid
gives the filter back to the session shown (selectSession re-subscribes).
Why it is safe, server side: the filter is read in exactly one place,
SseStreamManager.flushSessionTerminalBatch. The connect route parses it,
POST /api/events/subscribe replaces it (updateClientFilter); broadcast(),
the multi-user ownership check (canDeliver), the heartbeat, the order and
tab-layout frames and the shutdown notice never read it, and no push,
viewing or acknowledgement logic does. The only page consumer of
session:terminal is _onSSETerminal -> _onSessionTerminal, a no-op while
tiles own the terminal.
Measured at checkpoint 1 (6 tiles, focused tile a printing shell): 16 to
18 frames/s, 2.2 to 2.4 KB/s parsed and dropped -> 0.
Live, this code (6 tiles, shells printing), SSE terminal frames per 5 s:
0 with the grid open; 0 after an SSE reconnect with the grid open (connect
URL sessions=tile-grid); 86 in the single view after closing the grid and
86 after a reload into it (that connect URL names no session, as before;
selectSession's re-subscribe names the shown one). Just before this commit:
18 frames/s, 2.4 KB/s. A tile focus runs no connectSSE and no handleInit;
it posts the grid id.
Tests: the page subscribes with the grid id on open and on every tile
focus, gives the session back on close and on a reload into the single
view, and connectSSE asks the same helper. Server, live, multi-user: the
id is taken on the connect query and on a re-subscribe, and then withholds
terminal output while session:updated and hook events still reach their
owner (and only their owner). Mutation-checked five ways (helper ignoring
the grid, connectSSE on the raw id, the server dropping non-UUID ids on
subscribe and on connect, the filter gating every event).
Scope: PR 2 (constants.js, the app.js SSE seam).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_selectTiledSession added a once animationend listener to the focused
tile's tab on every focus. On every skin but OG the glow is `animation:
none`, so animationend never fires: the listeners piled up on the tab (and
the class stayed). It now glows a tab only when it is not glowing already,
so a tab holds at most one; on OG the animation ends, the class goes and
the next focus glows again.
Measured (50 tile focus changes, daylight-blue): animationend listeners on
the tabs 0 -> 49 before, 0 -> 6 (one per tab) after. Over the leak run's
20 grid open/close cycles the page's listener count grew 1003 -> 1042;
this is the part CDP could attribute.
Live, this code: 50 tile focus changes leave 6 animationend listeners on
the tabs, one per tab.
The single view's copy of the same block (app.js selectSession) has the
same leak on every non-OG skin; it is a separate, pre-existing copy and is
left alone here.
Test: ten focus changes leave one listener; after animationend the next
focus glows again; mutation-checked.
Scope: PR 2 (tile-grid.js).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
a pane changes, and with a grid of tiles that is every record the i18n
observer gets (about 4,600 a second for six printing tiles, all of them
row rewrites). Each paid one closest() over the whole skip selector list
(the per-record skip of 21beacf7, which stays). Every row of one terminal
shares its parent, so that parent's own shouldSkip() verdict is now kept
once it says skip: same verdict, one closest() per terminal instead of one
per record. A rows container outside any skipped surface keeps the full
check, so nothing that was translated stops being translated.
Measured (6 printing tiles, 30 s profiles, n=3 interleaved A/B, load 5.8
to 9.5, equivalent class-check variant): observer 391 to 445 ms -> 47 to
52 ms per 30 s in English, 473 -> 58 ms in zh-CN; main-thread script time
-0.35 s per 30 s (-14%). Frame share unchanged within noise.
Live, this code (6 printing tiles, 30 s profiles, interleaved against the
file at 00440c02, load 4.4 to 6.6): observer 417 to 430 ms -> 56 to 58 ms
per 30 s; script time 2.63 to 2.79 s -> 2.24 s in the undisturbed window.
The other window of this code was disturbed by CPU contention on the box
(every rendering cost 3 to 4 times higher, xterm's own included, 351 frames
in 30 s) and is not counted; its observer time was 56 ms all the same.
Test: 60 row rewrites in a terminal cost one closest(), the rows stay
untranslated, and a stray `.xterm-rows` outside any skip surface is still
translated (the verdict closest() gives); mutation-checked both ways.
Scope: PR 2 (i18n, on top of 21beacf7).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A window resize reached every grid tile twice: the grid's own
ResizeObserver refits them (tile-grid.js _scheduleTileGridRefit, 150 ms
trailing), then the main terminal's trailing resize timer (terminal-ui.js
throttledResize, 300 ms) ran _forEachTile(fit) over them again. The second
pass re-measured six panes and sent nothing (_lastSentDims dedupes the PTY
side). The timer now refits the split's Pane B only ({ grid: false }); grid
tiles exist only while the grid owns the terminal, and then its observer
already covers them.
Measured (6 tiles, 20-step window resize and back, headless, tileperf):
fit() 12 -> 6 per resize burst; PTY resizes 6 -> 6; browser layouts
unchanged within noise (85/75 -> 92/71), so this removes wasted calls only.
Live, this code (6 tiles, tileperf): a 20-step window resize, and the resize
back, each ran fit() 6 times and sent 6 PTY resizes (12 and 6 before).
Test: the timer's one _forEachTile call passes { grid: false } (it lives
inside initTerminal, so read from source like the #464 geometry tests);
mutation-checked.
Scope: PR 2. The line is in terminal-ui.js (a PR 1 seam), but on PR 1 alone
_forEachTile reaches only the split's Pane B: the double refit needs the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile's replay (writeChunked) wrote its capture 32 KB per animation
frame, so a 1 MiB load took about a second of frames, and in the grid the
load queue's slot was held across all of it: tile N+1's capture waited for
tile N's last frame. xterm 6 already parses its write queue in 12 ms
slices and yields between them, so the slices now all go in at once (up
to a 1 MiB window, since xterm's queue throws past 50 MB and Pane B's
unbounded full=1 capture can reach the server's 32 MB) and the replay
resolves on the callback of an empty write queued behind them, i.e. once
xterm has parsed the last slice. The single-flight flag is still held for
the whole replay. A disposed xterm never runs that callback, so destroy()
now settles a replay in progress: a removed tile can no longer hold its
flag or the grid's one load queue. Queued up front, the capture also stays
in one piece during a refresh: live output written meanwhile lands after
it, not between two of its slices.
Measured (tileperf, 6 printing shells with 1 MiB histories, headless,
n=3 interleaved A/B against the starting file, load 8.6 to 11.8):
- grid fresh open, 6 tiles, all painted: 5.10 s -> 2.57 s (-50%);
restore after reload: 6.49 s -> 3.86 s (-41%); per-tile replay
669 to 734 ms -> 298 to 321 ms (median).
- Same work in half the time: frames over 20 ms 26% -> 40% of the
(shorter) load window, about 86 -> 62 slow frames in all; longest long
task on restore 304 -> 227 ms; server event-loop delay unchanged
(max 111 to 122 -> 122 to 134 ms, one capture in flight throughout).
- Split Pane B (the other TerminalTile) with the main terminal on WebGL
and its long-task guard armed: load 1.6 to 3.8 s -> 0.8 to 1.7 s over
15 loads each; 0 long tasks of 200 ms or more either way, the guard
never tripped. With an unbounded full=1 capture (about 21k lines):
2.5 to 3.4 s -> 1.9 to 2.6 s, 0 long tasks of 200 ms or more.
- At checkpoint 1 (equivalent patch, n=3 to 6): fresh 6.4 -> 2.7 s,
restore 8.9 -> 4.2 s, TUI-style reconnect 11.2 to 11.8 -> 6.2 s.
Tests: the replay queues every slice at once and holds the flag until
xterm has parsed it; a replay larger than the window goes one window at a
time; a pane destroyed mid-parse settles at once; in the grid, a tile
destroyed while xterm still parses its replay releases the queue and the
next tile loads (fake xterm whose callbacks never run). The rAF-driven
tests now hold the parse callbacks instead. All mutation-checked (no
settle in destroy, settle before the parse, no window). Browser
split-pane-terminal: same 1 failed / 2 passed as at the starting HEAD
(the failure is in the test's own setup, before connect).
Scope: PR 1 (terminal-tile.js writeChunked and destroy(); Pane B replays
the same way). Moving it onto PR 1 needs its two call sites adapted
(PR 1 has no _runLoad yet) and leaves the tile-grid-load-queue.test.ts
hunk with PR 2.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec records decision 9 and an as-built entry replacing the "+ / New
session in this case" one, and marks the target picture, the header line
and the + bullet as built without it. CLAUDE.md's header list, the
invariants' z-index line (the + menu's layer) and the wiki's Tile Grid
page (the header string, its table and the cap sentence) drop the +.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner: "remove the + button from these views". The tile header is now
● name ... ⋯ ⤢ ×. Gone with it, because nothing else used them: the +
menu (openTileAddMenu, closeTileAddMenu and their hooks in closeTileGrid
and the global Escape handler), its "New session in this case" entry and
runInCaseForTiles, the .tile-add-empty rules, the four i18n entries only
the menu showed, and buildTilePickerSessions' exclude argument (only the
menu passed it).
Every other way of adding tiles stays and needed nothing from the menu:
the Tiles button and its right-click picker, Ctrl/Cmd+click on a tab,
dragging a tab onto a tile or an empty slot, "Open group as tiles", and
Run joining the open grid (_joinTileGridFromRun). The picker list, the
cap helper _tileGridLimit and the user-text skip on names are shared and
kept.
Tests: the + menu cases (picker, cap, auto-join, the zh-CN harvest) are
removed; tile-grid-chrome pins the header as exactly ⋯ ⤢ × with no add
menu or runInCaseForTiles left on the app.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: translate "Run SH" and the rest of the Run family, plus
the two leftovers from the last report.
Run: one pattern turns "Run <code>" (Run CC, Run SH, Run OC, Run CX ...,
and any registry CLI's shortBadge) into "运行 <code>"; the mode codes and
product names stay, and exact entries still win ("Run Shell" was already
运行 Shell, "Run OMP" 运行 OMP, "Run PI" takes the "Run Pi" entry). New
entries: "Terminal / Shell" (the run menu's shell item) and "Send Enter"
(the phone toolbar's Enter button title). The phone overview's Run button
already showed 运行 beside a mode word kept as typed.
Help modal and shortcut overlay: "Tabs", "Toggle Session Sidebar", both
"Copy Selection" rows, "Focus Tabs", and "Wheel" (滚轮, a mouse input like
Click). Key names stay English: the Help modal's Home key, and every key
the overlay renders, now carry data-i18n-skip, because "Home" is also a
dictionary word (the Home button) and showed as 主页 in the key column.
The invariants' paneExit section gains the badge's translation rule
(from the previous commit): its updates compare with the remembered
English, never the DOM.
Tests: i18n-exit-run-help covers every Run label _applyRunMode can show
(its hard-coded ones and Run <shortBadge> for every stock CLI), the
toolbar titles, the Help modal through the real translator in JSDOM (no
English outside the key column, the Home key kept while the word Home
elsewhere still translates, Wheel translated), every shortcut registry
group and label, and that the overlay's keys are skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: translate the EXITED badge. The badge carried
data-i18n-skip on purpose, because its in-place update compared the DOM
with the English label: a translated badge would never have matched, and
every incremental tab pass would have written English back for the
translator to redo. The tab's accessible name (which carries the exit,
the badge being aria-hidden) was set unconditionally on every pass, the
same trap once it is translated.
Now the badge is left to the translator. applyPaneExitBadge remembers the
last English badge text (data-label) and accessible name
(data-aria-source), both also seeded by the full render, and compares
with those, never the DOM. The tab strip's incremental path updates tabs
in place (no row-HTML comparison), so nothing else re-renders on a
translated badge.
i18n.js: "exited" -> 已退出, patterns for "exited (N)" -> 已退出(N) and
"exited (signal N)" -> 已退出(信号 N), and for the accessible name
"<name> session, agent exited ..." -> "<name> 会话,智能体已退出 ...", the
session name passed through untranslated. The header strip, the session
sidebar and the vertical rail all host the same tab markup, so this
covers all three. English reads exactly as before.
Tests: session-pane-exit-ui pins the new markup, the remembered English
and that a translated badge and accessible name survive an unchanged
pass; i18n-exit-run-help runs every paneExitLabel form (and the
accessible name, with names that are dictionary words) through the real
translator in both languages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Drawing only the selected tab's family hid every other connection until you
clicked into one. All families are drawn again (still as routed trees); the
selected tab's families (what it spawned, and the family it was spawned into)
get .lineage-family--focus: 2.5px instead of 1.5px, full opacity instead of
0.75, a larger end dot, and drawn last so nothing covers them.
Lanes follow strip order (cycling through CodemanLineage.MAX_LANES, 3), never
the selection, so a family's lines never move when you click a tab.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found live in zh-CN: the + menu's session items stayed titled "The grid
holds at most 6 tiles". Each item carried data-i18n-skip on the whole
button to keep the session name as typed, and the translator skips an
element's attributes along with its text. Only the name is skipped now (a
child span, as the picker does), so the title is translated.
The zh-CN coverage test classified a title inside a skipped subtree as
user text, which is how this got past it; such a label is now a failure
of its own ("no UI label sits inside a skipped subtree").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner request: with App Settings language set to 简体中文, the grid reads
fully in Chinese. Every string the grid puts on screen gets its own
ZH_CN entry, so none reaches the generic leading-verb fallback: the Tiles
button (both states, with the right-click hint), the Tiles and Split
chips, the grid region, the Help modal's Tiles rows, the shortcut
registry's Tiles group and labels (overlay and App Settings list, and
"not bound"), "Open group as tiles", the picker, a tile's +, the header
buttons, the Attach overlay (not attached, attaching, exited, ended, the
hint), the empty slot, the dividers, the Split button while tiles are
open, and the toasts. Strings with a count, an exit code or a duration
are translateDynamic patterns: the cap texts (both wordings, with and
without ": the new session opens on its own"), "This window fits N
tile(s)", the auto-zoom hint, "The agent exited (N)" / "(signal N)", the
crash-restart confirm (the existing confirm wrapper runs it through t();
the session name passes through untranslated, in the single view too),
and the tile header tooltip ("idle 3m"), which requires the duration so
bare state words stay out of the table (they collide with other
surfaces, see mobile-overview.js).
Wording: 平铺 for the feature, 窗格 for one tile, 附加 for attach, 智能体,
案例, as the table already has them. Key names stay; Click, Right-click
(mouse actions) and Arrows in the Help modal's key column are translated.
English reads exactly as before (only additions to the table).
test/tile-grid-i18n.test.ts drives the real tile code through every
state that writes text, harvests each string and requires Chinese with
no Latin word left beyond key names and durations, and the same English
in en; plus the markup through the real translator in JSDOM, and session
and group names (also when they equal a UI word) staying untranslated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three refreshes compared the DOM with the English source: the tile
header's tooltip, the Attach overlay's text and the zoom button's title.
With App Settings language set to 简体中文 the i18n observer writes the
translation into the DOM, so the comparison never matched again and every
chrome refresh (each session:updated, several a second with busy tiles)
wrote the English back for the observer to translate once more. Each now
remembers the last English value on the tile entry and compares with that.
English mode behaves exactly as before.
Also: tileShortcutFor's comment still called the inert chord a default
pending the owner's answer; it is owner decision 6.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the grid closed, Ctrl/Cmd+click on a tab opens what the Tiles button
would show (decision 8) plus that session; the wiki only said it opens
the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lineage lines used to draw one glowing dashed bezier per parent/child pair,
hanging below the tab strip, for every family at once. With a parent on row 3
of a wrapped strip and ten children below it, the curves crossed every lower
row's tab names and ran through the terminal text.
- One rounded orthogonal tree per spawning tab: every route starts at the
parent, so siblings share a trunk. 1.5px, solid, 1px dark outline; only a
working child's branch is dashed.
- Routes run only through the gaps between tab rows, joined by a spine left of
every row (computeLineageTree/computeLineageRows in constants.js), so they
never cross a tab or reach the terminal. The vertical rail gets the same tree
on its existing left track.
- Only the selected tab's family is drawn (what it spawned, plus its parent and
siblings). Selection redraws, and the strip's size transitionend redraws once
more, since the active tab widens for ~150ms after the selection redraw.
- The routing room is reserved by .session-tabs.lineage-tree, keyed on whether
any lineage exists, never on the selection, so a tab switch never resizes the
header or the PTY.
- Colours stay per spawning tab, now claimed in strip order for every family so
selection order never decides who gets the skin blue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback: the Header buttons group's keywords named neither Split
nor Tiles. Both are now in the group's data-search. The filter
(_filterSettings) matches each chip by its own data-search and its label,
and never by the wrapper's keywords (which would light up every header
chip for "split"), so the two chips also get their own: "tiles tile grid
side by side several sessions" and "split pane side by side two
sessions". Before, only the label words matched; "tile grid" found
nothing. Pinned by running the real filter over the real markup (JSDOM).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner feedback: the tile header's ⋯ ⤢ + × read as tiny next to the
session name. The buttons inherited the header's 12px font. They are now
26px click targets (min-width, so a wider glyph still fits) with a 16px
glyph, the same as the app header's own icon buttons (.btn-icon-header);
the thin ellipsis and cross get 19px so all four read at one visual
size. The header grows from 24 to 28px to hold them, the inline rename
input to 22px. Checked live at DSF 1 on a dark (daylight-blue) and a
light (paper-gray) skin, focused and unfocused tiles, and a zoomed tile.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner decision 8 ("when I hit the tiles button, open the tiles already!").
A click with the grid closed now opens it straight away, and Ctrl+Shift+G
runs the same function (toggleTileGrid), so the two cannot drift. What
opens comes from one pure helper, tileGridOpenSet (constants.js):
a. the grid this tab last had, if any of its sessions survive, opened
exactly (an open split closes and its sessions do not join);
b. else an open split's two sessions, Pane A focused;
c. else the open sessions in tab order (the picker's list: no detached
ones), up to what the grid takes here (the cap of 6, fewer when the
window fits fewer), the active session always among them and focused.
A click with the grid open still closes it.
The picker moved to right-click (oncontextmenu, browser menu suppressed).
With the grid open it is preselected with the current tiles, and Open
replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the
toggle's set plus that session. The button's title, the Help modal and
the wiki say right-click chooses which sessions.
Docs: decision 8 and an as-built entry in the spec (Entry points too),
CLAUDE.md, the invariants (#tile-grid, Opening), the wiki's Tile Grid and
Keyboard Shortcuts pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Six was tested smooth on the owner's desktop; nine missed the headless
frame bar and is untested on real hardware. TILE_GRID_MAX (constants.js)
is now 6 and stays the one cap every limit reads; the layout table gets
its own bound, TILE_LAYOUT_MAX = 9, so the 7 to 9 layouts keep working
(unreachable) and going back to nine is that one line.
Every way in stops at the cap: opening, addTile, a tile's +, a session
Run makes, Ctrl/Cmd+click, the picker, "Open group as tiles", and a stored
grid with more ids (it comes back as its first six, focus kept only if it
survives, a dropped zoom cleared, row fractions that no longer match the
3x2 reset). The limits now go through one helper, _tileGridLimit(), whose
texts say which limit binds: "Up to 6 tiles" / "The grid holds at most 6
tiles" when it is the cap, "This window fits N" when it is the window.
Docs: decision 7 and an as-built entry in the spec, CLAUDE.md, the
invariants, the wiki's Tile Grid and Dashboard pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
xterm's DOM renderer replaces terminal rows every frame (the split pane's
Pane B, every tile of the grid), and the translator's MutationObserver
walked each added row and ran closest(SKIP_SELECTOR) for every text node
and element in it, only to find each one inside .xterm and skip it. A CPU
profile of six printing tiles put about 2.2 s of 40 s there (closest,
translateNode, tree walks).
Now one shouldSkip(mutation.target) per record decides it: every node a
record adds or edits sits under that target, so both translators would
return on their own closest() check anyway, and the output is identical.
Measured in headless Chromium, six tiles printing 20 lines/s each, three
interleaved 40 s pairs at the same machine load: frames over 20 ms fell
from 9.1/11.5/11.8% to 7.3/8.1/8.0%; nine tiles 9.5% to 7.2%. Pinned in
i18n-branding.test: a burst of terminal rows causes no tree walk, terminal
text stays untranslated, application DOM beside it still translates.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spec listed it as the default applied while the owner's answer was
pending. The owner has decided: with showTileGridButton off the chord is
inert and passes through like any unbound key; on, it toggles the grid.
Recorded as decision 6 in the spec (with a line under Gating), and as an
owner decision in CLAUDE.md and the invariants.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A session Run makes while the grid is open joins as a tile right away, so
the tile connects and sends its size before Run starts the pane. The
server only records a resize for a session with no PTY and spawns the pane
at 120x40, and Run's own resize step measures the parked main terminal
(display: none, so nothing). Measured live for Shell and Claude: a 97x17
tile over a 120x40 pane, for good (#464).
The chrome refresh now remembers each tile's last-seen pid and calls
TerminalTile.paneStarted() when it appears or changes. paneStarted()
forgets the sent size and sends it; a hidden tile (a zoomed neighbour)
sends nothing and keeps it forgotten, so its next fit() sends it, which a
plain fit({ force: true }) would lose. Keyed on the sessions map, so a
handleInit after an SSE drop counts too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- CLAUDE.md: a Tile grid paragraph beside the split-pane one (parking, the
one load queue, the selection and close rules, chords, dividers, auto-join,
the exited-agent case), tile-grid.js (7.6) in the load order, the
desktop-gated header markers and the Tiles picker in the z-index stack.
- docs/architecture-invariants.md#tile-grid: the mechanisms and the reason
behind each rule; the split section now says where a waiting grid load
differs and that every capture carries a deadline.
- docs/wiki/Tile-Grid.md: the user manual page (turning it on, the ways in, a
tile's header, keys, leaving, persistence, Split), linked from the sidebar,
The Dashboard, Keyboard Shortcuts and Settings Reference.
- The Help modal lists the tile chords (pinned in help-modal-shortcuts.test).
- docs/tile-grid-plan.md: status updated, and an "as built" list of where PR 2
went another way than the spec.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every Run path makes each session it created visible through
_ensureCreatedSessionVisible and then selects the first one, a human
selection that used to leave the grid for the single view. That helper now
hands the new session to _joinTileGridFromRun: with the grid open it joins the
next free slot, so Run's selection focuses its tile. No Attach overlay flashes
on it while Run starts its pane. Sessions created elsewhere (agents, other
devices, cron) arrive only by session:created and never join; a grid already
holding what the window fits does not take it, and a hint says the new
session opens on its own.
A tile's + adds "New session in this case": the normal Run (current run mode)
for the case the tile's session belongs to, with the toolbar's case put back
afterwards; the session it creates joins the grid like any Run from this tab.
Disabled for a session outside every case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
showTileGridButton gets the full per-device treatment Split has: a header
chip in App Settings beside Split, its load and save lines, OFF by default
(and in the handheld defaults), a member of the displayKeys merge policy,
stripped from the settings PUT and never declared in the .strict()
SettingsUpdateSchema (sending it would 400 the whole save).
The setting also gates the Ctrl+Shift+G toggle (the applied default while the
owner's answer is pending; one line in tileShortcutFor to change): OFF, the
chord is inert and reaches the terminal like any unbound key; ON, it opens and
closes the grid where one can open. A grid that is open however it was opened
(Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords,
the toggle that closes it included.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grid is stored in localStorage `codeman:tile-grid` as
{ v: 1, open, ids, focused, zoomed, colFr, rowFr }: ids, focus, a zoom the
user chose (an automatic one is worked out again from the window) and the
divider fractions, never content. It is written as it changes (layout, focus,
zoom, divider drags); closing the grid keeps it remembered as open: false for
one-click return, and the last tile leaving forgets it. That stored state is
now the only "remembered" grid, so the Tiles toggle, the picker's preselection
and Ctrl/Cmd+click all bring back the grid this device last had, across
reloads. Never written or read in a solo window.
The restore runs INSIDE handleInit, in place of its single-view
selectSession(restoreId, { auto: true }), so with a stored open grid the main
terminal never loads on that page load (its first select would pull a
whole-history capture only to be parked). The stored ids are sanitized against
the session list (deleted, detached and duplicate ids dropped, anything that
is not a v1 object ignored), and the fractions and zoom go back on. A window
too narrow for the grid keeps the single view and the stored grid waits; a
#session= link on load wins and leaves the grid remembered but closed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found live: the attach and shell routes report a refusal in the envelope of
a 200 ({success: false}), and Attach read only res.ok, so a refused attach
remounted the tile as if it had worked. It now reads the envelope.
The refusal in question: an agent that exited in a live pane (paneExit, e.g.
a shell ended with `exit 3`) still has the pane's tmux client running, so
both routes refuse to start anything ("Session already has a running
process"), and the single view has no restart for it either. Its tile now
shows the exit with a pointer to Close session instead of an Attach button
that cannot work. A session with no PTY attached, or one whose socket closed
because it exited (4009), still gets Attach.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three more ways into the grid:
- Drag a session tab from the strip onto a tile: a session not yet tiled
replaces that tile in place (the replaced session keeps running); one
already tiled swaps places with it. A layout that is not full (3 tiles in a
2x2, 5 in a 3x2) shows its empty cells as slots, and a tab dropped on one
joins the grid there. Tiles and slots handle the drag in the capture phase
and stop it, because its payload is the session id as text and xterm's
helper textarea would type it into the PTY; a drag that is not a tab (a
file) is left alone. The dropped session takes focus (a human selection).
A sorted or grouped rail does not offer tab dragging, so neither does this.
- Ctrl/Cmd+click on a tab puts that session in the grid and focuses it,
opening the grid on what the Tiles toggle would bring back if it was
closed; on a window too narrow for the grid it stays an ordinary click.
- "Open group as tiles" in the grouped rail's group menu makes the group's
live sessions (as many as the window fits) the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grid now places every tile explicitly (grid-column / grid-row, reading
order) with a 6px divider track between columns and between rows, instead of
relying on DOM order and a gap. Track sizes are fractions (grid-template fr
values) that reset to equal whenever the column or row count changes.
Dragging a divider trades size between the two tracks either side, each kept
at the minimum tile size (the pure dragTrackFractions in constants.js, always
computed from the fractions the drag started with, so it cannot drift). The
affected tiles reflow locally at most once per animation frame, with no PTY
resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles
in other tracks hear nothing. Pointer capture keeps the drag on the divider,
the body locks the resize cursor and text selection for its duration, and
closing the grid or removing a tile mid-drag tears it down, as the split's
divider does. A zoomed grid shows no dividers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Tiles button beside Split in the header, opt-in through the per-device
showTileGridButton setting (read in applyHeaderVisibilitySettings; the
settings checkbox, displayKeys membership and schema exclusion follow with
persistence) and hard-gated like Split: hidden by its --hidden marker, a JS
width check with a live media listener, a @media (max-width: 1179px) backstop
and never in a solo window. While the grid is open the button closes it and
reads as pressed.
Closed, it opens a picker: a checkbox per open session in tab order (never one
popped out to its own window; one with no PTY is offered, its tile shows the
Attach overlay), names as text, preselected with the grid this tab last left,
else the active session and an open split's two. Boxes past what the window
can fit are disabled with the count shown, and Open opens the grid on the
checked sessions, focusing the active one if checked. Escape and an outside
click close it; its close method is idempotent and the global Escape handler
calls it.
A tile's + lists the open sessions not yet tiled; picking one adds it and
focuses it (a human selection). A grid that already holds what the window fits
disables the entries. "New session in this case" waits for auto-join.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile has no live terminal when its session has no PTY attached (pid null,
e.g. restored after a server restart), when the agent exited in a live pane
(paneExit), or when the server closed the tile's socket because the session
exited (4009, which used to leave only the "session ended" marker). Its body
now says which, with an Attach button, in an overlay laid over the terminal
so the body and its xterm keep their size.
Attach is the single view's own re-attach: POST /interactive (or /shell for a
shell) with NO body, at most one in flight per session, since the route has
no in-flight guard of its own. A tripped PTY-exit breaker goes through the
same confirm before clearBreaker: true, and nothing automatic ever sends it.
On success the tile is remounted onto the new pane (a socket stopped for good
cannot reconnect), keeping the keyboard if it had it; the overlay stays away
while the server catches up, and a failed attach says so and keeps it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
⤢ in a tile's header, or Alt+Shift+Enter (registry entry zoom-tile, applies
only while the grid is open and is swallowed before the Shift+Enter newline
gate), makes that tile fill the grid like tmux zoom. The other tiles stay
connected but hidden, so they measure nothing and send no resize; pressing it
again restores the grid and refits every tile, since the hidden ones have a
stale size. Zooming a tile that is not focused focuses it first (a human
selection).
As in tmux, moving focus to another tile restores the grid, and so does
removing the zoomed tile or adding one while a tile is zoomed by hand.
When the grid area cannot fit the tiles' minimum size, the grid zooms the
focused tile itself with a hint; that zoom follows focus and lifts once the
window fits again. A zoom the user chose is left alone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every tile gets a fixed-height header above its body: `● name ......... ⋯ ×`.
- The dot is the six-state classifier the tab rows and both home screens
share (_sidebarRichRow), with the existing .home-sessions-dot--* classes;
hovering the header says the state and for how long ("working 3m"). A
tile whose session waits on a permission prompt or question gets a pulsing
red border (box-shadow only, never layout; still under reduced motion).
- The name is text with data-i18n-skip; a double-click renames it through
the tab rename's own write queue (Enter or leaving the field commits,
Escape cancels, an IME composition owns Enter), and an in-flight name shows
as already applied, as on the tab.
- ⋯ is the tab rail's session menu (options, new window, close session with
its confirm); × removes the tile ONLY, the session keeps running. Neither
button focuses a tile that is not focused.
- The header is fixed at 24px so nothing in it can resize the body, and with
it the xterm and its PTY (#464).
Every tab render refreshes the headers, so they follow status and name
changes. Tabs of tiled sessions carry .in-tiles (both render paths), and the
tab strip re-renders when tiles come and go.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tile-grid-park-guards.test.ts carried its own copy of the fake DOM, written
before test/mocks/tile-grid-vm.ts existed, including the remove() that spliced
the wrong element when a child was no longer listed (fixed in the shared copy
only). It now uses the shared harness, which gains what the guards need: a
settable clock behind performance.now, the PerformanceObserver callbacks the
code under test registers, and localFit on the fake tile. Same 35 cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
xterm paints its viewport black, and a tile's rows rarely fill it exactly, so
every tile showed a black strip between its last row and its bottom edge. The
main terminal's container already makes the viewport transparent; tiles get
the same rule, so the gap shows the terminal background.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable):
- Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step
back after a selection outside it), else an open split as two tiles, else
the active session as one tile; pressed again, back to the single view of
the focused session. xterm emits nothing for a shifted Ctrl letter; the
browser's find-previous is overridden only where the grid can open.
- Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the
tile in that direction.
- Remove Focused Tile, unbound: the session keeps running.
tileShortcutFor() decides whether a chord applies (the toggle wherever a grid
could open, the rest only while one is open, so outside the grid
Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. The
capture handler dispatches it, and the main terminal's and every tile's xterm
key handler return false for it, for every event type and before the
Shift+Enter gate, so a chord that applies never reaches a PTY.
Coexistence with the split pane: opening the grid over an open split closes
it (no wasted resize for the pane about to park) and seeds the grid with both
of its sessions, Pane A focused. While the grid is open openSplitPicker and
openSplitPane refuse and the Split button reads as unavailable
(aria-disabled); closing the grid never reopens a split.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
selectSession gets the tile branch, right after its "already active" early
return: a tiled session is focused in its tile (_selectTiledSession: an
activeSessionId change, the shared _refreshSessionPanels and xterm.focus(),
no cleanup, replay, resize or socket of the parked main terminal). Decision 1:
a USER-initiated pick of a session that is not tiled leaves the grid for the
single view (the grid is remembered), and so does an explicit leaveTiles; an
app-driven pick (auto) never collapses it. A followed #session= link passes
leaveTiles (navigation).
App-driven paths pick a tile instead of the first sessionOrder entry:
- closeSession on the focused tile focuses the neighbouring tile (next in grid
order, else previous), captured before the await like wasActive, since the
delete broadcast may already have removed the tile; the last tile closes the
grid and falls back to the normal pick.
- A tiled session deleted elsewhere loses its tile and a neighbour takes focus
with auto (the last one lands on the welcome screen as before); a close from
this tab only drops the tile and leaves the follow-up to closeSession.
- A tiled session popped out to its own window leaves the grid.
Focus rules: pressing a tile is a human selection (pointerdown, never
preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a
human selection acknowledges an idle alert. Home leaves the grid (remembered);
killing every session closes it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tile-grid.js (load order 7.6) adds the grid to CodemanApp: openTileGrid,
closeTileGrid, addTile, removeTile and _selectTiledSession, over a
<section class="tile-grid"> that is a SIBLING of .terminal-wrap and takes its
place under .main.tiles-active. Every tile is a TerminalTile with the grid's
one load queue, TILE_SCROLLBACK, a bounded load and its own per-device font
size (codeman-tile-font-size; Ctrl +/- sizes the tiles while the grid is open).
Layout comes from computeTileLayout; one ResizeObserver on the section refits
each tile (xterm and PTY together) on the trailing edge.
Opening parks the main terminal: _cleanupPreviousSession runs once (its
snapshot is right at that moment, and it closes the main socket), and
activeSessionId always names the focused tile's session, so the panels follow
focus. With the main socket closed, every main-terminal path that would write
the focused tile's output into the hidden xterm, fetch a capture for it,
resize it or reopen its socket now stands aside through _tilesOwnTerminal():
the SSE terminal, clear and refresh handlers, the dropped-output recovery,
the completion/error writelns, retryConnection and handleInit (both re-arm the
tiles instead; handleInit keeps live tiles and drops dead ones), sendResize,
throttledResize, the history re-pull, and the WebGL long-task observer, which
watches the whole page and must not count tile renders toward the main
terminal's sticky WebGL disable. The header connection state comes from the
tile sockets.
Closing destroys every tile, invalidates the main terminal's cached content
(snapshot, codeman-xs key, buffer cache) for every tiled id, since it predates
the grid, and replays the focused session fresh in the single view.
_focusedPane() answers with the focused tile and _forEachTile reaches every
grid tile. A tile whose socket stops for good is removed (4003, 4004, 4010) or
keeps its "session ended" marker (4009).
No entry point yet: the grid is opened from the shortcut registry in a later
commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /api/sessions/:id/terminal runs synchronous tmux calls on the server, so
N tiles loading at once would stall every WebSocket and SSE stream back to
back (and after a deploy restart all N reopen within the same second).
TerminalTile takes the options PR 1 deferred to the grid:
- scheduleLoad(tile, kind, run): every capture the tile fetches (initial
load, reconnect refresh, server {t:'r'} refresh, shell history pull) runs
when its owner says so. Absent (the split's Pane B), a load runs at once.
- scrollback (the grid passes TILE_SCROLLBACK) and fontSize.
- boundedLoad: a TUI tile loads the bounded full=1&tail= window, never its
whole history.
TileLoadQueue (terminal-tile.js, DOM-free) is that one queue: concurrency 1,
a history pull ahead of background refreshes, then the owner's rank (the grid
ranks the focused tile first, then reading order). A destroyed tile's waiting
loads are dropped unrun, and destroy() aborts the running fetch so the queue
moves on.
Also, for Pane B as well: the load now has a deadline covering the body
(CodemanFetchDeadline), so a capture that never answers cannot hold the
single-flight flag (or the queue) forever; a refresh clears the screen at its
turn rather than when it is asked for; and a close while a load only waits in
the queue writes the disconnected marker at once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
window.CodemanTileGrid (constants.js), the grid's pure half:
- computeTileLayout: columns x rows by tile count (1x1, 2x1, 3x1 on a grid
area at least 1800px wide else 2x2, 2x2, 3x2, 3x3), capped at 9, and
whether every cell clears the minimum tile size (480x240).
- tileGridCapacity: how many tiles a grid area can hold.
- sanitizeTileGridState: a stored grid (ids only) made safe to apply;
unknown, deleted, detached and duplicate ids are dropped, focus and zoom
must name a kept tile, track fractions must be sane.
- tileNeighbor / tileInDirection / cycleTile: which tile takes focus when one
leaves, on a directional chord, and on Ctrl+Tab or Alt+[ ].
- TILE_SCROLLBACK (10,000 lines, not the primary pane's 50,000) and the tile
font default.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The block selectSession runs in an idle callback once the terminal content is
on screen (respawn banner and countdown, action log, task panel, Ralph state,
CLI info, project insights, subagent window visibility, file browser) moves
verbatim into its own method. The tile grid's focus change needs the same
refresh without the rest of selectSession, and one copy keeps the two from
drifting. No behavior change: the stale-generation guard moves with it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.
Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Settings (per device): Git status: max repositories (1-50, default 12) and git timeout (5-120 s, default 30, was a fixed 10). Both go to /git-status and /git-diff as maxRepos / timeout query parameters, clamped server-side (an empty value means the default). A repository whose git status fails stays in the list with the reason instead of being dropped silently, shows as '? N' in the indicator, and the truncation line now names the limit and the setting. The discovery cache is keyed by the limit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
The menu opens upward (bottom: 100%) with no max-height or overflow, so with the stock CLIs plus custom endpoint entries it was taller than the room above the toolbar and its top was off-screen and unreachable, worst on phones. Cap it to the space between the header and the toolbar (dvh, vh fallback), scroll inside it with overscroll containment, and stop its overflow:auto children (history, saved URLs) being squashed.
Tests: a CI-gate source guard and a real mobile-browser test (touch swipe reaches the last entry), which fails without the CSS.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
A pending 229 edit plus Enter in one page task: xterm clears the textarea for CR before the edit timer runs, so the timer diffed the whole line against '' and sent one DEL per character ahead of the submitted line. The pending diff is now applied synchronously from handleKeyEvent, before flushPending() (which keeps the orphan candidate from sending the character twice).
Tests: unit (3) and browser (4, local echo on and off, plain last character and autocorrect), each verified to fail without the settle call. xterm-private-api guard now names the CompositionHelper fields this depends on and checks the shipped bundle; CLAUDE.md notes the edit-based 229 diff.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
When SSE comes back after a server restart, handleInit's reconnect
branch already re-opens the primary pane's socket; it now also calls
the split pane tile's reconnectNow(), so Pane B no longer waits out its
backoff (up to 10 s between tries) after every deploy. Live: Pane B was
back 4.6 s after the server process respawned, i.e. as soon as it
listened.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the split open, every app-level terminal action resolved against
Pane A: Ctrl+L typed into Pane B cleared Pane A's display while xterm
sent the ^L into Pane B's PTY, and Ctrl+Shift+R restored Pane A's size.
_focusedPane() now answers with the terminal focused LAST (a mic or
header click moves DOM focus but not the user's pane): Pane B claims it
from its own textarea's focus, the primary terminal's focus gives it
back, and a destroyed pane never holds it. Ctrl+L, Ctrl+Shift+R, voice
dictation and image paste act on the focused pane. Ctrl+W deliberately
still closes the active session: it kills with no confirm, so moving it
is an owner decision (docs/tile-grid-plan.md, decision 5).
Also destroys every tile after each terminal-tile-input test: a real
reconnect timer from one test opened a socket in a later one and flaked
under full-suite load.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Pane B registers the primary pane's file-path/URL link provider on
its own terminal, so a path an agent prints there opens the file
preview or log viewer for Pane B's session (it was plain text).
- Ctrl+V/Cmd+V in Pane B goes through the primary pane's paste trap,
aimed at Pane B: a pasted image uploads to Pane B's session and its
path is typed there; text keeps its bracketed-paste markers. xterm's
default handled text only.
Tests pin both the tile wiring and the targeted link provider itself
(registered on the target terminal, opening against the target's
session at click time, the primary's tap-path provider untouched).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Font size, family and weight changes refit Pane B AND tell its PTY.
They used to reflow the xterm only, leaving the CLI wrapping at the old
column count, the garbled-redraw class #464 fixed for the primary pane.
- The resize frame reports the size the xterm actually holds, with no
40x10 floor (the divider's 20% clamp leaves about 28 columns), skips
an unchanged size, and is always re-sent on a fresh socket so it
re-registers as a desktop viewer.
- The server's {t:'zc'} geometry report is handled: a different column
count is adopted, rows stay local, using the primary pane's own
reconcilePtyGeometry verdict.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Codeman restart (every deploy) or a network blip used to leave Pane B
dead, with a marker asking the user to close and reopen the split.
TerminalTile now reconnects:
- A transient close reconnects on the primary pane's backoff ladder
(CodemanWsReconnect) plus jitter; the attempt count resets only on a
successful open. The redelivery sweep's forced close (1005) counts as
transient.
- On reopen the closed state is cleared before the buffer refresh that
closes the output gap, so no stale marker lands under a healthy pane.
- 4003/4004/4009/4010 stop the pane for good and report once through a
new onExit(code) callback; the marker says why.
- Sockets are replaced race-free: the old one is detached before a new
one opens, and every handler ignores events from a socket that is no
longer current. destroy() cancels a pending reconnect.
- reconnectNow() lets an owner skip the backoff.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
xterm diffs the helper textarea with newValue.replace(oldValue, ''), which only works when the keyboard appended. SwiftKey/Gboard autocorrect on space deletes a word and inserts the corrected one, so xterm sent the whole value and then the inserted text again (testing the peompt + space became 'testing the peompttesting the prompt rompt '), and a multi-character delete was one DEL. The keyCode-229 controller now swaps in an edit-based diff against what was already sent.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
TerminalTile used to send every xterm onData chunk as a bare {t:'i'}
frame: no seq, no ACK, silently dropped while its socket was down, and
typing never acknowledged the session's idle alert. Keystrokes and
pastes now go through app._sendInputAsync over the tile's own socket,
registered in the input-socket map while open (HTTP fallback while
not), so they are ACKed, persisted until delivered, redelivered after a
drop, and the ACK clears the idle alert.
What xterm generates on its own stays out of that persisted queue: a
query reply (DA/CPR/OSC) is dropped, as the primary pane drops it, and a
focus or mouse report goes out once via _sendInputEphemeral. The tile's
socket carries the tab identity with a :tile suffix so it can never
evict the primary pane's socket.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pure move and rename, no behavior change. The split pane's second
terminal (SplitTerminalPane) moves out of terminal-split.js into its own
terminal-tile.js (load order 7.4) as TerminalTile, so the tile grid can
reuse it. terminal-split.js keeps the split orchestration (picker,
divider, auto-collapse) and constructs a TerminalTile for Pane B.
Tests follow the class: split-pane-terminal-unit becomes
terminal-tile-unit, and the Shift+Enter guard and the two browser suites
read terminal-tile.js / window.TerminalTile. The browser suites match
master (one pre-existing environmental failure in both).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both read activeSessionId at the END of an async gap, so switching tabs
in between sent the input to the wrong session:
- An image upload inserted its paths with sendInput(), which re-reads
activeSessionId after the uploads finish. It now inserts into the
session the batch was uploaded to, through the same durable queue.
- Voice dictation read the target when the transcript arrived and again
when the send button or the compose overlay's Send was pressed. The
target is now captured in start() (via _focusedPane()), the local-echo
overlay is only used when that target is the active session, and a
target that closed meanwhile gets a toast instead of a 404.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:
- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
exactly-once input queue, its ACK handling and the redelivery sweep now
deliver over any registered socket bound to a session, not only
this._ws. ACKs are routed by the receiving socket's session; silence is
judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
copyTerminalSelection and _handleImagePaste take an optional target
terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
(primary only, for now); _forEachTile() replaces the _splitPane special
cases in the font, family, weight, skin and resize paths.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compact now draws every reading the same way, ring then label then
value: CPU and MEM get an accent ring (red past 80%, like their value)
in place of the sparklines, the plan windows keep their green, yellow
or red rings, the dot separators go, and both pills share one label and
one value style. The sparkline markup, history and CSS are removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Plans a grid of up to nine live sessions side by side. All tiles are
equal TerminalTiles, the main terminal is parked while the grid is
open, and activeSessionId follows the focused tile. The split pane stays
and shares the tile class. PR 1 builds the seams and TerminalTile, so
the split's second pane gains reconnect, exactly-once input, links,
image paste and focus-following shortcuts. PR 2 adds the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Idle is the default state of a tab, so naming it only adds noise. The
idle group is now quiet: its heading element stays (it anchors the row's
order band and, as the first row, holds its place beside the brand) but
draws no label or count. Needs you, Waiting and Working are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Header icon buttons next to the Tiles take the tile box (36px, border,
fill, 18px glyph) and turn into round chips beside the Compact pills;
the gear's quarter turn moves onto the glyph. The ledger's active cell
gets an inset second pixel and an accent bar (the skin's 1px !important
border was too quiet in a grid of look-alike cells), and every ledger
cell stretches to a 30px minimum so rows stay one height.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' |
'ledger' | 'classic', default 'state'), the first row of App Settings,
Appearance, Tabs, so the old and the new strip are one choice apart.
- By case (option A): each case's tabs sit in one .tab-cluster box in
first-appearance order, labelled with the case and its count, coloured
by a stable hash into the session palette. Membership is
_mobileOverviewCaseFor(), the home screens' own match. Inside a box with
company a generated w75-api-gateway reads w75; the -<case> stays in the
DOM in a .tab-name-case span only .tabs-clusters hides. The incremental
render path rebuilds only when the cluster structure key changes. The
rail and the sidebar get a labelled section per case; phones dissolve
the boxes into the chip row. Drag stays inside one box.
- Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an
auto-fill column grid of equal cells in mono type with a 3px status
bar. Its markup is identical to classic's.
- State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the
by-state groups so needs you can be the bottom row.
- By state: the label column is measured to the widest label on screen
and the labels are right-aligned in it, instead of a fixed 92px gutter
that left short labels far from their tabs.
- Tiles: a three-row grid (label, value, bar) with pixel line-heights in
the bundled JetBrains Mono, 36px like the header. The bar used to lie
over a fixed 28px tile, and a taller system mono (SF Mono) pushed the
value into it. The WS tile's grid moved onto an inner .connection-tile
span because JS writes the indicator's display inline. Compact uses the
same font and a matched WS size.
Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed
order in test/tab-triage.test.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two directions from Discussion #426, each a per-device setting and the
new default.
Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.
Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.
Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Admission counted cells, rows, merges and styles only in worksheets,
styles.xml and workbook.xml, so the objects ExcelJS builds per element
elsewhere (shared-string runs, fonts, fills, borders, comments, drawings,
VML, tables) were bounded only by the inflated-byte caps, and empty stored
deflate blocks pad a stream past the ratio cap. createXmlCounter now counts
every start tag in every part except the pure-bytes xl/media/<name>.<ext>
entries into counts.elements and refuses above LIMITS.maxElements
(2,000,000) as element-limit. Parts that read no attributes carry only a
trailing '<' between chunks, so long text or binary is never taken for an
oversized tag.
Very tall sheets now scale only the scroll position: the scroll range maps
onto the sheet's whole range and the tile is laid out at real row heights
and column widths, with spans clipped at the spacer, instead of dividing
every cell and heading by the scale.
- opencode-resize: record WebSocket resize frames as well as POST /resize,
seed the needsRefresh test with real PTY output, skip the OpenCode close
modal test when opencode is not installed
- split-pane: send the marker with useMux:false (plain prompts otherwise go
through tmux send-keys, which test mode does not have) and retry past
Codeman's own post-create clear
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
cap (probeBeforeTouching), and whatever is still unknown is skipped. The
per-spawn hook and statusLine helpers used to fall back to an unbounded
lstat/readFile there, which on a dead workspace never settled and could take
the last threadpool workers (and hang the boot hook sweep). New test: cap
engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
GET /api/cases/:name now say a folder was not checked (other mounts are still
not answering) instead of blaming a healthy folder at the stall ceiling.
errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
.unreachable covers a refused probe, the boot sweep skips an unanswering
workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
(plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
block, and the cap-below-ceiling test no longer depends on an inherited
UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- createEditCoordinator's finally block rebases the edits queued during a write; one that the write's 409 made inapplicable was dropped with no toast. It is now reported once, like the main loop and adoptExternal do (found by the PR bot's re-review; regression test fails without it).
- At the 32-group server cap the row and group menus no longer offer a new group, which could only fail with an untranslated 'group limit reached'. MAX_GROUPS is exported from tab-layout-browser.js.
- CLAUDE.md names the pagehide keepalive as the one deliberate exception to 'never PUT the layout outside the coordinator'.
- The Dashboard wiki page describes tab groups in the vertical rail row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A cached list of repositories below a folder is re-checked against the
Docker case workspaces as they are now, so a repository linked as a Docker
workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
(unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
repository list (findWorkspaceRepo) and refreshes only that repository,
instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
any full status, so an unrelated repository above the workspace costs one
process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The doctor now judges candidates like the run mode's resolver: the PATH
hit, then each search dir, each one version-checked on its own and
skipped on a mismatch (a wrong `pi`/`grok` on the PATH no longer hides
the real one in a search dir). A search-dir candidate must be an
absolute path to an executable regular file, so a relative dir or a
file without the x bit reads as missing, as it does in the Run menu.
`isExecutableRegularFile` is exported from cli-executable-resolver.ts
and reused rather than copied.
- Every doctor probe passes killSignal: 'SIGKILL'; a --version that
ignores SIGTERM held the probe for its full runtime (15 s vs 5 s
measured with a TERM-trapping script).
- README no longer claims parity with the Run menu or nvm prefixes.
- The Diagnostics panel marks a missing optional tool with ○, a missing
required one with ✗, as the terminal doctor does.
- expandSearchDir names its twin, expandHome() in cli-resolver.ts.
- test/doctor-cli-json.test.ts is hermetic: temp HOME, a PATH of only
`which` and `node`, and a clis.json that drops the registry's absolute
search dirs, so it never runs the machine's installed agent CLIs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Route test hygiene: each test works in its own mkdtemp folder, every
deletion goes through safeRmHomeTree, and the suite refuses to start
outside test/setup.ts's temp HOME, so a raw `npx vitest` can no longer
delete a real ~/projects or the live linked-cases registry.
- Path policy: the symlink-resolved target is also judged against the
resolved home, data dir and system roots (home reached through a link,
macOS /etc -> /private/etc); test expectations are realpath-safe.
- Refuse a target equal to or inside the caller's or the shared cases
directory, pointing at plain Create New (it would list twice, and
deleting the local copy removes files).
- The registry re-read comment no longer claims to prevent the
lost-update race; documented as narrowing it, like /api/cases/link.
- UI: the success toast names the folder the server created, the
"under ~/codeman-cases" blurb and name hint change while a custom
folder is ticked, a "/" parent previews and sends /<name> instead of
an empty path, and the new labels have zh-CN entries.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The behavioural check for the detailed-rail rename clamp (#534, #526) lives in test/inline-rename.test.ts, a browser suite the CI gate does not run. This pins the cascade from styles.css itself, from computed selector specificity and source order, so a later clamp rule cannot silently out-rank the shared unclamp again. Mutation-checked: deleting the detailed-rail twin fails exactly that case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server
# Conflicts:
# src/web/routes/case-routes.ts
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
# Conflicts:
# config/test-suites.ts
# docs/api-reference.md
- applyWorkspaceHooks: an "unknown" probe that is not near a stalled path
(refused by the stall cap, or an unexpected stat error) no longer reads as
"go ahead". It checks existence with pathExistsForWrite first, so a deleted
workspace is not recreated by the mkdir -p in ensureCodemanHooks.
- pastCap gets a hard ceiling, PATH_PROBE_STALL_CEILING = UV_THREADPOOL_SIZE
(default 4) minus one, so explicit requests against several dead paths can
never take the last libuv worker. The bulk cap now defaults to one below the
ceiling (2 with the default pool), leaving a slot for an explicit request.
- A stall widens to its mount only for network and FUSE filesystem types read
from /proc/self/mounts; on a local mount (a path typed under a local /home
that reaches a NAS through a symlink) it narrows to the stalled path.
- GET /api/cases/:name probes CLAUDE.md with pastCap, like the folder probe.
- Comment in config/path-probe.ts describes the mount-scoped stall.
- richTextPrefix visits at most maxCellTextChars + 1 runs. An empty run adds
no text, so a length check alone walked every run of a shared string for
every cell referencing it, on every tile.
- Two or more unsupported number format warnings in a tile fold into one
"N unsupported number formats" entry, and the notice bar has a max-height
and scrolls.
- General-format and unsupported-format numbers render at 15 significant
digits, as Excel does (0.1+0.2 shows 0.3).
- TIME_FORMAT accepts a trailing AM/PM, so h:mm AM/PM renders as 2:30 PM
instead of falling back to a date.
- SPREADSHEET_ASSET_VERSION refreshed.
Reopening the editor over a rename still in flight filled it from the name
the server had not replaced yet, so dismissing it (blur commits) queued the
old name behind the new one and undid the rename. The queue now records the
newest queued name per session (_inlineRenamePending, cleared with the queue
entry), and a reopened editor takes its prefix, input and "unchanged"
comparison from it. An untouched confirm sends nothing more.
A failed write only toasted while its editor was still current. The queue
reports the failure itself now, and the editor only puts its label back.
One rejected task blocked every later rename of that session until reload.
Each task now chains from a settled predecessor, the local apply after a
successful PUT is guarded, and the queue entry is cleaned up on either
outcome.
The rail and sidebar editor's 4rem floor moves from a stylesheet
`!important` into the inline min-width startInlineRename already writes per
layout (0 in the header strip, 4rem in the rail and sidebar).
Tests: the reopened-editor case now expects only "First" to be sent; new
cases cover a 500 answered after the editor is gone and a throw in
updateSubagentParentNames; the long-prefix check runs in the sidebar and
detailed sidebar too and asserts the inline floor; the header strip editor
keeps min-width 0.
Two problems with renaming a tab in the vertical rail, both easier to hit now
that the grouped rail has its own inline editor beside the session one.
Writes. A committed rename PUT its name and only applied the answer if the
same editor was still open when it came back. Reopening the editor before the
PUT answered (F2 or right-click again, or starting a group rename, which
cancels the session editor) threw the confirmed name away, so the tab kept
showing the old name until an SSE frame happened to repaint it. Two quick
renames also raced as two concurrent PUTs. Inline renames now go through a
per-session queue: one PUT at a time in the order they were made, the
confirmed name applied to app.sessions whatever happened to the editor, and
the "already that name" check made when the write runs rather than when Enter
is pressed, so confirming the name still on screen over a write in flight is
a real write.
Layout. The editor (a flex row) could not shrink below the input's intrinsic
width, so a long w<n>-<case> prefix pushed the label past its row: the prefix
slid out of view in the detailed rows and the input was clipped mid-word in
the compact rail. The label now has min-width 0, the prefix gives way first
(down to 2rem, with an ellipsis), the input keeps 4rem, and in the compact
rail the row's adornments step aside while the name is edited. The detailed
rows' three-line clamp also outranked the shared unclamp rule, which is what
the existing "unclamped editor" browser test caught; it is restated there.
Header strip, sidebar and flat-rail markup are unchanged.
Tests (test/inline-rename.test.ts, browser suite): the unclamp check runs for
simple and detailed rows; a write-ordering describe covers ordering, a
reopened editor cancelled over a confirmed write, a re-sent unchanged name and
a group rename taking over; a long-prefix describe drives real rows from a
live session in simple, detailed and compact rails.
- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host
- a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green
- turning the setting off during a poll releases the in-flight flag
- log.showSignature=false; reword the docs: clean filters still run
- CLAUDE.md frontend load order, changeset names git-diff
- discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials
- keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
The Git window shows each group's files under their folders, collapsed until clicked, with single-child folder chains merged and open folders surviving the refresh. App Settings → Bottom bar → 'Git status: group files by folder' (per device) switches back to the flat list.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
Rows open an in-panel diff (staged, not staged, untracked as additions, deleted as removals) via GET /api/sessions/:id/git-diff, with Back and Open file. The route matches repo and path against the current status, runs git diff read-only (--no-ext-diff --no-textconv), and caps output at 400 KB.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
- doctor probes each CLI's discovery.searchDirs when which misses and runs --version on the resolved path, so a service with a minimal PATH no longer reports installed CLIs as missing
- GET /api/doctor shares one in-flight run per category
- Diagnostics group hidden from non-admins in multi-user mode (_applyDoctorAdminGate)
- 500 uses INTERNAL_ERROR; a killed child reports 'timed out after 30 s'
- browser test blocks service workers so page.route() is reliable
- wiki: Diagnostics sentence
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).
- probePath()/probePathKind() are tri-state: present (or directory/file),
absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
point from /proc/self/mounts, never /; just the path itself when there is
no mount table). Unrelated paths keep probing. The process-wide cap is a
backstop that answers unknown, and a single-path user request can probe
past it ({ pastCap: true }), still bounded and still recorded as stalled.
One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
unreachable linked case answers with its registered path and
unreachable: true; a local one answers OPERATION_FAILED. runClaude and
runShell create a case only on errorCode NOT_FOUND. The case list keeps an
unreachable linked case, marked unreachable, instead of dropping it, and
fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
is absent or on the stalled mount; a capacity refusal no longer stops
hooks being installed elsewhere, and an unreadable settings file never
lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
probe with the bounded probe instead of statSync/existsSync. Missing and
non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
(default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
- Pointer drag: a press released outside the rail no longer lingers. The
release is heard on window while a press is pending, a move with the
primary button up cancels it, a new press cancels any previous drag, and
an existing Escape listener is removed before another is added, so no
orphaned capture listener can swallow Escape before the terminal.
- Inline group rename: a commit by blur leaves focus where the user put it;
Enter and Escape still return focus to the header.
- A failed layout read while edits are pending keeps the held layout and the
editor and re-reads once the write settles, so a 409 is still rebased.
Dropping unsaved work now always says so in a toast.
- "Move to <group>" quotes the group name (with a matching zh-CN pattern), so
a group named "New group" or "ungrouped" no longer reads or translates like
the fixed entries.
- The group menu glyph stays visible under (hover: none).
- The sessionStorage replay copy carries { owner, baseVersion, savedAt } and is
ignored for another owner, after 60 s, or against an older layout. A move
with no anchor carries no index, so a replay keeps the row last.
- A 400 that survives the re-read is reported as "Could not save tab groups."
- closeTabRailActionMenu() no longer removes the group menu's DOM.
- Cancelling "Delete group" returns focus to the header.
- Stale comments updated.
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.
Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
reorderGroup, moveRef) applied to the rail at once, mirroring the server
model: a moved session takes the sessions that still follow it, and a
hand-moved child is marked placement 'manual'. normalizeLayout now keeps
placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
made in the same turn share a write; edits made while one is in flight go
out on the version it returns. A 409 replays the operations onto the
layout the server returned and retries (bounded); an operation that no
longer applies is dropped and reported. A 400 re-reads first; any other
failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
that changes nothing.
Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
Ungrouped and Move to new group in the vertical rail. Before the first
group exists it offers only "Move to new group", which is how a flat rail
becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
a hover glyph (a non-focusable aria-hidden span, so the treeitem still
holds no interactive child): Rename, New group, Move group up/down,
Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
back to its row or header), a pointer outside, Tab, focus leaving it, a
resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
the current editor releases the render guard. Enter or blur commits,
Escape cancels, IME composition keys are left to the IME, and the label
becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
row or into a group, a header drag reorders groups. Escape cancels; the
click that ends a drag neither selects nor toggles. The flat rail and the
header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
once it settles; a read otherwise rebases unsaved edits. On pagehide,
unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.
Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.
Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
A linked case can live on a network mount. When that mount goes away, a
hard mount makes stat() wait indefinitely, and the existsSync() probes in
the case routes and the workspace hook/statusline helpers ran on the event
loop, so a single GET /api/cases (or a session create in that workspace)
froze the whole web server until the mount came back.
Add boundedPathExists() (src/utils/bounded-path-probe.ts): an async stat
that answers "absent" after 1.5 s, shares one in-flight probe per path,
remembers a timed-out path until its stat finally settles, and refuses to
start new probes while two stalled ones still hold libuv threadpool
workers. Route the read-side probes in case-routes.ts and hooks-config.ts
through it. The settings writers in hooks-config.ts use an async lstat
that treats only ENOENT as missing, so an unreachable workspace is never
mistaken for an empty one and has its settings recreated.
Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is
read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its
lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files,
commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window.
Which repositories: the enclosing one when there is one; otherwise every repository up to two
levels below the working directory (capped, skipping dot-folders and node_modules, never
following symlinks), each in a collapsible section, with the indicator summing them. A repository
that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is
ignored. Git-supplied text is only ever written with textContent.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
POST /api/cases takes an optional path; Add Case > Create New gets a 'Create in a
custom folder' option with Browse. The folder is created (or an empty one filled),
scaffolded like a normal case and registered as a linked case. System, home,
credential and Codeman folders are refused; a folder with files is Link Existing's
job; a failure after the first write undoes what this call created. Admin only in
multi-user mode, like Link Existing.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The card-row rule (line-clamp: 3) out-ranked the shared unclamp-while-renaming
override. Restate it at the same weight; the test now covers both rail layouts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
One consolidated minor changeset with the Thanks block first; the four contributor changesets (#520, #521, #522, #523) are folded into it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fixes for the three findings of the third review round of #499.
- minor: a composition on an empty prompt did not follow the prompt after
output or a resize. The post-write re-place in flushPendingWrites and the
resize observer both ran rerender() only when hasPending was true, and
hasPending deliberately excludes the composition, so the first word of a
prompt (an overlay holding only a composition) stayed on the old row over
whatever output moved there. Both sites now call rerender() unconditionally;
it already returns early when there is nothing to draw, so nothing changes
without a composition. New browser case drives the real
batchTerminalWrite/flushPendingWrites path against real xterm 6 and the
overlay built from source, moves the prompt from row 0 to row 3 and checks
the overlay follows (it fails on the old guard, overlay left on row 0), with
a parity case for pending text. The structure test pins the post-write site
through vm and the resize site, which is a closure inside initTerminal(), by
source.
- nit: removeChar() dropped the composition but did not repaint on its false
path, leaving a composition-only overlay on screen showing text the addon no
longer held. It now hides the overlay there when a composition was dropped.
Package tests cover that path and the flushed path repainting without the
tail.
- nit: the package README did not document setComposition() or the
composition getter and described hasPending as "any content". Added both to
the API tables plus a short IME composition section, reworded hasPending
(pending or flushed text, excludes the composition), and made the quick
start re-render unconditionally instead of teaching the hasPending guard.
The hasPending JSDoc says the same.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the three PRs that landed together on the
session create / launch / persistence path.
#514 findings (bot verdict merge-with-fixes):
- minor, fixed: SessionState.model was published and persisted for every
mode, so a codex/opencode cron session reported the app-wide Claude
default it never ran on. toState() now emits it only where the new
cliTakesSessionModel() holds (registry capability model.source ===
'claude-settings-file', no CLI id branch). POST /api/sessions uses the
same helper for its non-claude refusal, so refusal and publication cannot
drift. Recovery then hands back undefined for other modes on its own.
- nit, fixed: the `model` schema admitted a leading dash (and '.', '[').
The first character must now be a letter or digit; still a subset of the
registry's model-claude pattern, so nothing accepted is refused at launch.
- nit, fixed (reject, the consistent choice): `model` with
attachRemoteSession was silently dropped. Now a 400 INVALID_INPUT, as
#514 does for non-claude CLIs and quick-start does for remote cases.
advisorModel (#530) gets the same refusal there. effort and envOverrides
keep their older silent ignore on that branch so no existing caller breaks.
#515 finding (bot verdict merge, one nit):
- nit, fixed: the types/session.ts @fileoverview described CodexConfig as
(model, resumeSessionId); it now lists reasoningEffort, bypass,
animations and renderMode too.
Audit of the merged combination (not reviewed before):
- The conflict resolutions in session.ts (toState), types/session.ts,
reboot-restore-routes.ts, server.ts (restoreMuxSessions), CLAUDE.md and
skills/codeman/reference/endpoints.md (+ plugin mirror) keep both sides
correctly; nothing was lost or doubled.
- A claude session with both `model` and `advisorModel` launches with
`--model <id>` and ONE merged `--settings` JSON (ultracode + advisorModel,
or advisorModel beside `--effort <level>`), on the tmux template
(including the resume || new variant and with the statusLine exporter)
and on the direct-PTY fallback. Both values (and effort) survive
restoreMuxSessions onto a dead pane, a reboot restore into a fresh pane,
and restartCli/dead-pane respawn via _buildRespawnPaneOptions.
- quick-start and ralph-loop take no per-session `model` (matching #514's
scope, POST /api/sessions only) and launch on the app-wide default, which
toState now persists for claude, so recovery stays consistent.
- No defect found in the combination beyond the findings above. Noted, not
changed: advisorModel is still published for any mode a caller sends it
with (launch-inert there; the UI and skill send it for claude only).
Tests: test/session-model-recovery.test.ts pins the pair through both
recovery shapes for effort ultracode/high/none, the recovery constructors'
fields, the tmux-manager builder hop, and the codex/opencode/shell
non-publication; test/advisor-model.test.ts pins the launch lines and a
real direct-PTY Session's pty.spawn argv; the route test covers flag-shaped
models, attach refusals and the published fields. Docs: SessionState.model
docstring, the reboot-restore-registry header, the golden test comment and
the CLAUDE.md model/advisor bullets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.
#517 minors
- A collapsed group hid rows that need the user with no signal on its
header. The header now takes the most urgent alert among the session
rows its collapse hides, in the tab alert language (tab-alert-action
red ring, tab-alert-idle yellow ring, the existing ::before rules
extended to the header). New pure hiddenGroupAlerts() over a per-section
`hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
paths, since alerts change without a rebuild. The kept selection draws
its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
every 5 s forever. _applyTabLayout() now rebuilds only when the
structure key changed. The key drops the layout version (bumped on
every session create/close and order PUT) and instead carries group
names and the rows each collapse hides, so a version bump that moves
nothing costs nothing and a rename still rebuilds. The load coordinator
backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
good. A parse or shape error now reads as nothing collapsed and is
rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
re-ranks per group, sends no session:orderChanged and leaves this
client's sessionOrder and Alt+N targets diverged. The move is now a
no-op unless the neighbour is in the active session's own section
(_canSwapActiveTabWith, reading the projection's new sectionByRef, which
also covers rows a collapse hides). Within a group the swap still works
and the server agrees with it; the flat rail and the strip are
unchanged.
#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
(a test cannot read another revision's files portably). Checked by
hand instead: the flat strip and flat rail markup of this branch before
and after this commit are identical in all 16 cases (both orientations,
manual and activity sort, no layout and zero groups, full and
incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.
#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
the roving tabindex parks those at -1, so the [tabindex] arm of
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
a row (Enter on the overflow button re-selected and reloaded the active
session instead of reopening its menu). It now returns unless the key
landed on the treeitem itself.
#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
re-sorted rows on the incremental path. The position pass is extracted
(_applyTabTreePositions) and re-run, with aria-selected and the header
alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
owning an empty group. A group with no open rows is now a tree leaf: no
aria-expanded, no aria-owns, its rows container presentation; Left and
Right do nothing on it, and its chevron keys off the section's
collapsed class instead of aria-expanded.
Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).
M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.
M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.
M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.
Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
settings to turn MCP sync on first" instead of calling the routes; the 403 message also
says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
the route imported them, so no churn.
Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).
Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.
Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.
Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.
Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).
Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Minor: the Key tester "14 lines" test never pressed a key into the
tester (the previous test blurred it, so the presses landed on <body>
and the cap was never exercised). It now refocuses the field, asserts
the focus, clears the log, checks 2 presses accumulate to 6 lines, then
4 presses of another key cap the log at exactly 14 with the oldest 4
lines evicted in order, and the readonly field stays empty. Verified to
fail with the cap changed to 20.
- Nit: the split-pane invariant implied Ctrl+Enter could use the CLI's
declared newline chord. Reworded after checking the send-key route:
Ctrl+Enter is always a real 0x0a, Shift+Enter is the declared
capabilities.newline chord (0x0a unless the CLI declares another), sent
on keydown only. The same imprecision in the auto-named sessions
paragraph is corrected too.
- Nit: docs/wiki/Settings-Reference.md now lists the Key tester row in
the Terminal & Input table.
- Nit: test/shift-enter-keypress.browser.test.ts exercised a hand-copied
predicate named `shipped`. It now loads the real app from a real
WebServer and presses real keys into the handlers terminal-ui.js
(app.terminal, recording the real _sendInputAsync send path) and
terminal-split.js (a real SplitTerminalPane) attach, recording the
send-key POSTs through a fetch wrapper. It asserts no \r reaches either
send path for Shift/Ctrl+Enter, exactly one send-key per press for the
right session, and that Enter and Alt+Enter are untouched. The old
keydown-only gate stays as a labelled reproduction of xterm's keypress
behaviour on a bare Terminal. Verified to fail on both panes with the
gate narrowed back to keydown.
- Nit: the keypress trap is now written down beside the other key-gate
rules (Command palette and shortcut registry): xterm runs the custom
handler for keydown, keypress and keyup and drops only Ctrl/Alt/Meta
keypresses, so a gate on a chord that can carry Shift alone must
swallow every event type. The smart-copy keydown-only rule points at it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Run menus (toolbar dropdown, phone overview picker, Custom Endpoint rows,
model picker) marked every backend with an 8px colour dot, so telling Codex
from DeepSeek meant reading the label. Each known backend now draws its own
logo in that slot. It is CSS only: every surface already renders
`.run-mode-dot <id>`, so no markup changes.
- Brand-coloured marks (Claude, Gemini, Antigravity, DeepSeek, OMP) paint as a
background image; monochrome ones (Codex, OpenCode, Pi, Grok, plus Shell and
web URLs) are masks over the row's text colour, so they follow every skin.
- Logos are inline SVG data URIs (img-src already allows data:), from
@lobehub/icons-static-svg 1.95.1 (MIT); the OMP mark is omp.sh's own.
- Drops the non-og skin overrides that re-tinted four dots with a
`background:` shorthand, which would have wiped the logo.
- An id with no logo (a clis.json addition) keeps a dot, now in --text-dim
instead of being transparent.
- test/run-menu-cli-logos.test.ts pins that every stock agent plus shell/web
has a logo in exactly one paint group and that nothing resets the slot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Stale second marker above a trailing refresh's replay: xterm parses
write() on a later tick while clear() is synchronous, so a marker stamped
in a load's finally, just before _endBufferLoad() starts the trailing
refresh, landed in the freshly cleared buffer above that refresh's replay.
_stampMarkerIfOwed() now returns early while a refresh is pending; that
refresh re-owes the marker on a closed socket and writes the one copy
below its own replay. Pinned by marker-count assertions on the two
existing trailing-refresh tests plus a new async-parse fake (writes
parsed on a later tick, clear() synchronous) for back-to-back refreshes
and a pull with a queued refresh and a close mid-pull; all four fail
without the guard. Also checked against a real @xterm/headless 6.0.0.
- Marker withheld for up to the 45 s request budget: kept the behaviour and
made the comment and the docs truthful. The pull's request phase holds no
live output, but it holds the single-flight flag, so a coalesced {t:'r'}
refresh and a close's owed marker wait for the response. Writing the
marker at once during that phase would need a separate "awaiting
response" state and, with a refresh pending, reopens the same
write-vs-clear() race as above; a Codeman restart resets the in-flight
request along with the socket, so that pull fails at once and stamps.
- Stale comments: _onSocketClosed() now says the deferral covers any load,
_writeDisconnectedMarker() points at _stampMarkerIfOwed(), and the pull's
finally comment describes the hand-off to a trailing refresh.
- Invariants doc: dropped "the initial load" from the loads a close can land
in (connect() awaits it before creating the socket), reworded the
"nested refresh stamps its own" sentence to describe the guard, and noted
what the request phase holds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ensureStatusLineExporterScript() rewrites ~/.codeman/statusline-exporter.sh
via a temp file + rename whenever the script content changes (a fresh data
dir, or a release that changes it). The temp name was pid + Date.now(), so
claude sessions created in the same millisecond (spawn_workers, a multi-tab
Run) shared one temp path: the first rename consumed it and every other
writer failed with ENOENT on chmod or rename. createSession() treats that as
a mux failure and falls back to a direct PTY, so those sessions silently ran
outside tmux (no reattach after a server restart) while quick-start still
reported success.
Measured on a fresh isolated instance, 4 concurrent claude quick-starts:
master put 2 of 4 in tmux in both rounds; with this change 4 of 4, both
rounds. The temp suffix now comes from randomBytes, like the skill writer in
the same file and user-store.ts already do. The new test freezes Date.now()
and runs eight refreshes at once; it fails on master with the same ENOENT.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
spawn_worker builds its quick-start body itself ({caseName, mode,
parentSessionId}), so an agent driving the skill had no way to give a worker
the advisor without hand-building the call and losing the readiness ladder,
hooks vetting and trust-dialog fallback. Setting CODEMAN_WORKER_ADVISOR
(fable / opus / sonnet) now adds `advisorModel` for every claude worker that
spawn_worker or spawn_workers starts; other modes ignore it.
A refused value fails the spawn with the server's INVALID_INPUT message. A
server without advisor support drops the field silently (the schema is not
strict), so spawn_worker reads it back and says so on stderr.
The preamble changed, so CODEMAN_PREAMBLE is bumped to 1.33.4 and stale
cached copies are rewritten instead of silently ignoring the variable.
SKILL.md's heredoc and the plugin mirror are synced.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's
main model consult a second, stronger model at decision points: before
committing to an approach, on a recurring error, and before declaring a task
done. Codeman can now start claude sessions with one.
- `advisorModel` field on POST /api/sessions, /api/quick-start and
/api/ralph-loop/start (fable, opus, sonnet or a full model id in those
families; haiku cannot advise and is refused). Stored on the session and
persisted, so respawn, boot restore and reboot restore keep it. Remote and
docker quick-starts refuse it, as they refuse effort.
- App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable),
synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it.
Default sends nothing, leaving the CLI's own /advisor choice in charge.
- Carried as the `advisorModel` key in the launch's single --settings JSON,
merged with ultracode and the statusLine exporter, never the --advisor
flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead
pane on every respawn, while the settings key degrades to no advisor. A
launch without an advisor is byte-identical to before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only
cell sits in XFD is a dictionary-mode array that `eachCell` and
`hasValues` (behind `eachRow`) walk to index 16,384. The worker walked
each row four times at load and once per tile, so a small file of
far-column rows took seconds to load and to tile.
`worksheetMetadata` now builds each sheet's row and cell index from
`Object.keys(sheet._rows)` and `Object.keys(row._cells)`, sorted
numerically, skipping falsy and Null-type cells exactly as
`eachCell({ includeEmpty: false })` does and keeping a row only when it
holds one such cell (`hasValues`). Styles, extent and row heights come
from that one pass and merges from `sheet._merges`; `sendTile` reads
each row's cells from the index.
`parseThemePalette` returns the default palette for a theme above
64 * 1024 characters, since its patterns are quadratic on unclosed tags.
ExcelJS stores a row at _rows[r - 1] and a sheet at _worksheets[sheetId], and
walks or slices those arrays up to the largest index, so the index a row or
sheet claims is a cost of its own. Admission now reads each <row> tag's
attributes in order and refuses an r outside 1-1048576 (absent r is fine), and
a counter for the resolved xl/workbook.xml reads every <sheet> tag and refuses
one that does not parse or whose sheetId is not plain digits up to
LIMITS.maxSheetId (65535).
sendTile no longer reads sheet.model, which rebuilt every row and cell model on
each tile: the merges read in worksheetMetadata are kept in mergesById next to
populatedRowsById, replaced on load and cleared on dispose.
Number formats cap decimals at 30, as Excel does; toLocaleString throws a
RangeError above 100 and the whole grid was replaced by the error.
Docs: CLAUDE.md and architecture-invariants describe both bounds and the merge
reuse. SPREADSHEET_ASSET_VERSION is recomputed for the edited worker and core.
Admission checked ZIP entry names as stored, but JSZip (inside ExcelJS)
resolves `.`, `..` and empty segments on load, and ExcelJS strips one
leading `/` and matches worksheets with an unanchored pattern. Names like
`/xl/worksheets/sheet1.xml` or `xl/worksheets/sheet1.xml.x` skipped every
counter. Admission now computes the name ExcelJS will see for each entry,
refuses two entries that resolve to the same name, keys the rebuilt
archive on it, and picks the worksheet/styles counters from it.
ExcelJS's DefinedNames model setter expands every range into one object
per cell. The preview never shows defined names, so the worker stubs
`_definedNames.model` before load.
Pin fflate to 0.8.3 (GHSA-px8p-9vwx-vf98) and refresh
SPREADSHEET_ASSET_VERSION.
- app.js: the shortcut dispatcher returns early for events aimed at a data-raw-keys
field, so Ctrl+W / Ctrl+L / Escape / Alt+1 / Ctrl+K pressed in the Key tester no
longer kill the session, clear the terminal or close Settings
- stock.ts: drop Codex's esc-enter (a line feed works); no stock CLI declares a chord.
The esc-enter path is tested through a clis.json override
- tests: unused port (3194), Ctrl+Enter asserts no keypress, shortcut-isolation test
(verified to fail without the guard)
- docs/comments point at capabilities.newline; set-input class, trailing whitespace
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Follow-ups from the #506 review.
The live-frame queue opened before the fetch, freezing Pane B for the
whole round trip. It now opens beside capturedAt; the request uses the
shared terminal fetch deadline and the body read a 10 s one.
A {t:'r'} refresh queued behind a pull ran its clear() after the
disconnected marker was written and wiped it, and a close during a
refresh load wrote the marker above the replay. The marker is now an
owed flag (_markerOwed) that each load settles in its own finally.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
capabilities.newline replaces choosing the Shift+Enter bytes in the send-key
route. Key tester shows the keydown/keypress/keyup a browser reports.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Opt-in (mcpSyncEnabled, default OFF; routes 403 until on). Review fixes:
- codex TOML read/validated with smol-toml: CRLF, inline tables and
command-less tables no longer yield a duplicate [mcp_servers.x]; the new
text is re-parsed before writing
- null-prototype tables and own-key checks; unsafe names ignored at every level
- servers switched off in their own CLI (codex/opencode/antigravity) are not copied
- only CLIs that are installed or already have a config file take part
- files receiving env/headers are left 0600; symlinked configs are written through
- one apply at a time (409), unique tmp files cleaned on failure, failed status
- routes set real HTTP status codes; api-reference section; format type single-sourced
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xterm runs the custom key handler for keypress too and drops Ctrl/Alt
keypresses but not Shift-only ones, so the stray \r submitted the prompt
after the newline. Swallow every event type for Shift/Ctrl+Enter and send
only on keydown, in the primary pane and Pane B. Adds a static guard and a
real xterm + Chromium browser test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds capabilities.mcpConfig to the CLI registry (Claude, Gemini, Codex,
OpenCode), an additive src/mcp-sync.ts, GET/POST /api/mcp-sync and a
Settings > Agents & CLIs control. Never edits or removes an existing
server; backs up each file it changes; reports conflicts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.
Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
role=tablist and label when grouping ends. The header strip, sidebar and
flat rail keep role=tablist / role=tab exactly as before (the flat rail's
markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
row a collapsed group keeps showing are level-1 items; a collapsed header
owns nothing, and the "Ungrouped" heading is a visual divider hidden from
assistive tech. aria-level, aria-setsize and aria-posinset are set on every
item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
leave the tab order, so Shift+F10 / ContextMenu open a row's actions
(session action menu, web tab settings).
- Up/Down walk visible items, Home/End jump, Right expands a header or enters
it, Left collapses a header or climbs from a row to its header, Enter/Space
select a row or toggle a header. With the activity sort on, the walk follows
painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
click through the same onclick and still the lineage proxy anchor.
Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
upstream; that is now pinned in real Chromium for the strip, the flat rail
and the grouped rail, together with every control (gear, detach, close,
overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
hovered (no reflow-on-hover moving the gear out from under a click).
New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).
XML allows a raw `>` and the other quote character inside an attribute
value, so a first-match search for `ref=`/`max=` could be fed a fake
value from an earlier attribute while saxes read the real one:
- <mergeCell>/<col> attributes are now read in order from the tag name
with a sticky regex that consumes each quoted value whole. A tag whose
attributes do not parse up to `>`, or that repeats a name, is refused.
- The chunk carry keeps everything from the last `<`, which can never
appear inside an attribute value, instead of comparing against the
last `>`.
ExcelJS keeps a Row object for every <row>, cells or not, so <row> tags
now count against per-sheet (100k) and total (250k) caps with their own
row-limit code, and the worker passes maxRows as a per-sheet backstop.
styles.xml counts every <xf> without tracking which list it sits in,
since a </cellXfs> inside a comment desynced that state.
The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout)
and draws its groups as collapsible sections. This is the first frontend
consumer of the tab-layout backend and it is read-only: nothing in the
browser writes the layout yet.
- tab-layout-browser.js (new, pure, loaded before app.js): projects the
layout onto the live sessions and open web tabs, renders the grouped
markup, stores collapse per device, and sequences loads newest-wins with
a bounded retry on failure.
- app.js: loads the layout on init and on tab:layoutChanged, renders the
grouped rail from the same per-row markup the flat rail uses, falls
through to a full render whenever the grouping structure changes, and
withholds drag-reorder in the grouped rail.
- Grouping is opt-in by construction. With no layout, a failed read, a
layout without groups, or a horizontal strip, the rail renders exactly
as before (byte-identical markup).
- Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the
palette keep reading the server-projected order, and row badges keep
their Alt+N slot.
- A collapsed group still shows the active row; lineage arcs to a hidden
session anchor to its group header.
- webview-tabs.js: renderWebviewTab() extracted so a single web tab can be
placed into its group with unchanged markup.
SessionState now carries the model a session launched with, and both
recovery constructors (mux recovery and reboot restore) pass it back, so a
recovered session relaunches on the same --model rather than the account
default. A top-level `model` sent with any other CLI is refused, since
those take their model in their own config object, and an empty string
means no per-session model, as it does for modelOverride.
CLAUDE.md now describes both routes for a Claude model. The tests pin
which of `model` and `modelOverride` reaches the launch and which the
case file, and that a model opening with a dash renders as --model's value.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both create schemas now refuse an unknown level, and a non-granted owner's
codexConfig keeps its reasoningEffort when the clamp forces bypass off.
docs/architecture-invariants.md lists the two --config values codex now
takes from codexConfig.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
codexConfig takes a `reasoningEffort`, one of the levels codex accepts,
and the session starts with `--config model_reasoning_effort=<level>`.
The registry declares one literal per level, gated on the enum, because
an argv token cannot splice a value into a literal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
POST /api/sessions takes an optional `model`, and a Claude session
launches with `claude --model <id>`. It wins over the app-wide default
model and writes nothing to disk, unlike `modelOverride`, which stays as
it is and still writes the case's .claude/settings.local.json.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ExcelJS 4.4.0 expands three constructs into one object per cell or column
at load time, so a few KB admitted as one cell could cost a gigabyte:
- a <mergeCell> now costs its full area against the per-sheet and total
cell caps, and a ref that does not parse is refused
- a <col> whose min or max is past 16384 is refused
- the worker loads with ignoreNodes: ['dataValidations']; the preview
never shows validations, and a whole-column dropdown took 5 s
The XML counter now scans up to the last complete tag and carries the
rest, so a merge or col tag cut by an inflate-chunk edge is read whole.
A central-directory compressedSize that runs past the file is refused,
since the ratio cap divides by it.
The renderer and core axis offsets use prefix sums with a binary search
instead of walking every override per call.
- A #session=<id> link whose session never appears (closed, a typo, or
another user's session in multi-user mode) is dropped after
URL_SESSION_WAIT_MS (30 s) with a "Session not found" toast instead of
waiting forever. One stored timer per link, cleared whenever the link is
followed, replaced by a newer link, or retired.
- goHome() and opening a web tab now retire a waiting link, so a session
that turns up later no longer takes the screen. App-made web tab opens
(frame self-recovery, the fallback after the active web tab closes) pass
auto: true and keep it, as selectSession() does.
- zh-CN translation for the new toast.
- selectSession's auto: true comment now lists the #session=<id> link.
- docs: the 30 s bound, a win.location.replace() tip that avoids piling up
history entries, and the fragment declared a stable SemVer surface in
versioning-policy.md.
- Tests: timeout drops and toasts, an early arrival is still selected, the
wait does not restart, goHome and a web tab retire it, an auto web tab
open keeps it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A markdown preview opened by attachment id under a bare file name
(attachment cards, history drawer) no longer resolves relative refs
against the workspace root: filePreviewText carries attachmentId, and
the rebase pass turns those images into their alt text and unwraps
those links. Absolute-path and workspace previews are unchanged.
- _renderMarkdown(text, { breaks = true } = {}): the File Viewer passes
breaks: false, so a hard-wrapped paragraph renders as one paragraph;
the Response Viewer keeps a <br> per newline.
- Absolute paths linkified inside a rendered document now carry the
preview's data-session-id.
- CLAUDE.md, architecture-invariants and the Working-With-Files wiki page
now say that only an in-workspace path clicked in the terminal keeps
the tail viewer.
- Tests in test/file-preview-markdown.test.ts for all three fixes,
including an end-to-end run of the shipping app.js + marked + DOMPurify.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- terminal-split.js: move the socket's close into _onSocketClosed(), which
defers the marker while a history pull holds live output (_liveQueue);
_pullHistory() records closedBefore and its finally writes the marker
after the queue flush when the socket closed during the pull, replayed
or not, so it never lands above held frames or between replay chunks
- tests: drive the real close path for a close mid-fetch ending in a skip,
a downgrade or a failed fetch, a close during the chunked replay, and a
close with no pull running; pin the onclose wiring in the static guard;
describe the mid-fetch case on its own
- CLAUDE.md: turn the plain-text split-pane pointer into a link
- architecture-invariants.md: describe the deferred marker
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- _restoreOverlayFocus(key, modal) now leaves focus alone when something
outside the overlay already holds it (not <body>, not inside the modal).
The Session Manager's "Switch to session" and "Open folder" call
selectSession() before closeSessionManager(), and the restore was pulling
focus back from the terminal to the header button. Both close methods pass
their modal; a regression test drives that order.
- Test harness: focusHarness() routes getElementById through a local binding
instead of leaking globalThis.__els, and its modal stubs report their own
search box as contained, as the real DOM does.
- CLAUDE.md and docs/architecture-invariants.md: record that the global
Escape handler calls every close method on every Escape (capture phase),
so a close method with side effects must return early when not open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review feedback. The global Escape handler in app.js calls both
`closeSessionManager()` and `closeCommandPalette()` on every Escape, whether or
not either overlay is open, in the capture phase. Nothing was saved in that
case, so `_restoreOverlayFocus()` fell through to `terminal.focus()` and moved
focus before the focused element's own Escape handler ran:
- split view: with focus in Pane B, keys typed after Escape went to Pane A
- any text field (File Viewer editor, search and history filters, case picker):
keys typed after Escape went into the terminal
- inline tab rename: the capture-phase focus fired the input's blur (which
commits) before its own Escape handler (which cancels), so Escape committed
the rename instead of cancelling it
Both close methods now bail out on `classList.contains('active')`.
Separately, gating the terminal fallback on `activeSessionId` alone only covered
the welcome screen. On a touch device with the keyboard down, focus sits on
`<body>`, so closing the Session Manager focused the terminal and brought the
keyboard up — `selectSession()` deliberately skips that focus, and this
overrode it. It now goes through `_shouldFocusTerminalForTabSwitch()`.
Tests: the Session Manager case's modal stub now uses the harness's
`makeClassList()` (without `contains` the new guard reads it as "not open" and
skips the restore the case is about), plus two new cases — closing either
overlay without opening it first with an active session asserts the terminal was
not focused, which is the path the global Escape chain takes and none of the
five existing cases covered, and a touch device with the keyboard down asserts
the same. Each was checked against the unguarded code: removing either guard
turns exactly its own case red.
Both the Command Palette and the Session Manager call `search.focus()` on
open, and both closed by removing the `active` class and nothing else. Hiding
a focused input does not hand focus back to anyone — the browser drops it on
`<body>` — so after Escape closed the overlay every keystroke went nowhere and
the user had to click the terminal before they could type again.
Measured in headless chromium against a real shell session, one overlay at a
time:
overlay activeElement after Esc can type afterwards
App Settings XTERM yes
Session Options XTERM yes
Token Stats XTERM yes
Monitor Panel XTERM yes
Session Manager BODY no <- fixed here
Command Palette BODY no <- fixed here
The four that worked did so because they use `FocusTrap`, whose `deactivate()`
restores focus to whatever held it before. These two never got one. Every close
path has the same hole — Escape, the close method, picking an item — so the
restore lives in the close functions rather than in the global Escape chain.
Deliberately only the save/restore half of `FocusTrap`, not the whole thing:
`FocusTrap.activate()` moves focus to the first focusable element, which in
neither overlay is the search box, so adopting it wholesale would trade "type a
filter the moment it opens" for "focus survives the close" — and the former is
the reason Cmd+K exists. The terminal fallback is gated on there being an
active session: an overlay opened from the welcome screen has no terminal to
return to, and focusing one on a phone summons the on-screen keyboard over a
screen with no input on it.
The five new cases were checked against the unfixed code first: four of them
fail without this change.
A page that keeps one Codeman window open, such as a task board, could only
show a session by sending that window to /session/<id>, which loads the whole
app again for every click. The dashboard now reads a #session=<id> fragment
when it loads and on hashchange, selects that session, and removes the
fragment with history.replaceState so the next identical link is still a
change. Re-pointing a window that already shows the dashboard changes only the
fragment, so the page stays loaded and the switch is a tab change.
A link can name a session the dashboard does not list yet, because the page
that created it may link before session:created arrives. The id waits until
that event names it, and picking another tab yourself retires it.
Following a link is an app selection (`auto: true`). The page that set the
fragment may be a script, so it must not spend the session's idle alert.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Address Ark0N's review on #506:
- The history pull's own `\x1bc` reset erased the "Pane B disconnected"
marker onclose wrote, painting a fresh, current-looking history while
onData kept silently dropping every keystroke on the dead socket — a
Codeman restart drops the socket while the tmux session (and so the HTTP
pull) survives, making this easy to hit. onclose now tracks the closure
via `_wsClosed` in addition to writing the marker (extracted into
`_writeDisconnectedMarker()`), and a replay re-stamps it in the pull's
`finally` block, after the live-frame flush, whichever order the close
and the pull land in.
- `_maybeLoadMoreHistory()` now stands aside for a detached session,
mirroring `_sendResize()`'s existing check and app.js's
`_maybeRefetchFullHistory()` — its own window already owns its PTY size
and scrollback.
- Wording: a non-shell CLI's history is out of scope for this pull, not
absent (codex and Claude's inline renderer do grow tmux history); the
alternate-screen skip only matters for a direct-PTY shell, since tmux
never surfaces the alt buffer to the browser xterm. CLAUDE.md points at
the invariants heading directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tmux repaints a burst of output instead of scrolling it, so a shell
pane's xterm keeps about one screen of scrollback while tmux holds every
line. The primary pane goes back for it when the wheel reaches the top;
Pane B is a separate xterm that loaded history once at connect and never
again, so after a `cat` its earlier output was unreachable.
Pane B now does the same for a shell session: wheel-up at the top of the
normal screen pulls ?full=1&tail=TERMINAL_TAIL_SIZE and holds the
reader's place across the replay. The wheel listener is capture-phase
because xterm stopPropagation()s the events it consumes.
It follows the primary pane's rules from #494 and its 1.33.2 merge-time
fixes: a window holding no more rows than the pane (which covers a
downgrade), or a pane already at its `scrollback + rows` cap, is skipped
without a rewrite. That skip backs off to 60 s when the window was
truncated or the pane is full, since each ask costs the server a
whole-history capture-pane; an untruncated window keeps the 4 s cooldown.
There is no truncation banner in Pane B, so the 'tail' relabel does not
apply.
Live frames, a {t:'c'} clear included, are held with their arrival time
while the replay runs and applied in order only if they arrived after the
capture. The fetch has a 10 s deadline since it holds live output while
it runs. The tail of _loadBuffer() becomes _endBufferLoad() so the pull
shares its single-flight bookkeeping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #503. marked percent-encodes link and image destinations, and the rebase pass encoded them a second time, so a space or a CJK character in a file name made file-raw look for a file literally named my%20image.png; refs are now decoded once (a malformed escape is kept as written) and stripped of ?query along with #fragment. Root-relative refs resolve from the workspace root as on GitHub instead of falling through as Codeman URLs. Rebased links carry the preview's own session id and the response-viewer delegate prefers it, so a document opened from another session's attachment card opens its links in that workspace rather than the active tab's.
The sanitizer no longer allows name=: marked never emits it, and <img name="app"> made document.app that image, which every inline onclick="app.…()" handler resolves before the global, so one rendered README broke every viewer button until a reload. Adds the zh-CN strings for the three toolbar titles.
A cron job in "Paste (direct)" input mode wrote `<text>\r` into the pane
in one piece. Claude Code (measured on 2.1.283) takes a burst of about a
hundred characters as a paste, so the `\r` landed as a newline and the
prompt sat unsent on the composer while the run reported `prompt_sent`.
Delivery now lives in `deliverCronPrompt()`. Paste mode writes the text
raw, waits CRON_PASTE_ENTER_DELAY_MS (300 ms), sends `\r` as a separate
write down the same PTY (so it cannot overtake the text), and arms the
session's composer check through the new public
`Session.verifySubmitted()`, which re-presses Enter while the prompt is
still visibly unsent. A session with nothing to write to now fails the
run instead of reporting the prompt as sent. Typed mode is unchanged.
Verified on an isolated instance: a paste-mode job with a 104-character
prompt submitted on the first Enter and Claude answered.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A prompt posted to /api/sessions/:id/input without `useMux` was written
into the pane in one piece. Claude Code (measured on 2.1.283) takes a
`<text>\r` burst of about a hundred characters or more as a paste, so the
trailing `\r` landed as a newline in the composer and the prompt sat there
unsent while the route answered 200. A later raw `\r` did not recover it;
a tmux `send-keys Enter` did. Short prompts submitted, which is why it
looked random. The same stranding was seen with Codex and OpenCode.
A plain prompt (printable text plus exactly one trailing `\r`, detected by
`isPlainPromptInput()`) now goes through `writeViaMux` even without
`useMux`: the text is typed, Enter is pressed as its own key, and the
SubmitVerifier re-presses it while the prompt is still on the composer.
The write is awaited, since the browser's POST fallback sends frames one
at a time and a following keystroke must not overtake the Enter. Raw
frames (escape sequences, bracketed paste, a line feed, a bare `\r`) and
an explicit `useMux: false` keep the direct write.
Verified on an isolated instance: the 239- and 104-character prompts that
stranded (at +1 s, at +50 s on ultracode, and on a warm session) all
submitted on the first Enter with no `useMux`. The phone's local-echo
flow (a burst, then its `\r` as a separate write) was measured unaffected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folds the #490 and #492 contributor changesets (the latter said minor) into one patch changeset with the Thanks section, one paragraph per change and the fixes applied while landing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- _logScrollRouting() reports cliMouseTracking, the gate's new input, in both
the de-dup signature and the console line (xterm's own mouseTracking stays
'none' for Claude, so it gave no reason for a no).
- Restore two guard tests the new gate made vacuous: the local-scrollback
opt-out footgun test and the codex/gemini "no version rescues it" fixtures
now set cliMouseTracking: true, so removing the opt-out or re-adding codex to
the gate fails again.
- Update the comments and architecture-invariants lines that still described
the version-only rule (wheel handler header, gate doc, the false paths of
_maybePageCliTranscript, "holds a tracking mode on continuously").
- Name both fullscreen switches (CLAUDE_CODE_NO_FLICKER=1 and "tui":
"fullscreen" in ~/.claude/settings.json) in the code comment, the invariants
and the two wiki pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Skip and latch a bounded Shell window once the browser is at xterm's
scrollback cap (scrollback + rows): a 1 MiB window of short lines can carry
more rows than the browser can ever hold, so it replayed and re-captured on
every scroll-to-top with no 60 s back-off.
- Label a replayed bounded window 'tail' even when the capture was byte-capped,
so the banner keeps offering Load full history instead of calling the rest
unrecoverable.
- Pin GET /terminal?full=1&tail=<n> in the route tests: full-history source,
truncationReason 'tail', and the closing relative cursor move survive the cut.
- Log the bounded skip via _logScrollRouting('repull-skipped-bounded').
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- pre-push hook: skip with a notice when npm is not on PATH (GUI git
clients and IDEs often run hooks with a minimal PATH), instead of
blocking every push on "npm: not found"; real-push test with a
stripped PATH
- test/git-hooks.test.ts: pin GIT_CONFIG_NOSYSTEM=1 and
GIT_CONFIG_GLOBAL=/dev/null around the resolveGitHooksDir tests, so
an exported global or a system core.hooksPath no longer fails them
- watch tsconfig.json, .prettierignore and .editorconfig too:
typecheck and format:check read them
- check:browser-excludes: fail loudly when the vitest list output and
the walked test/**/*.test.ts tree share no path (format drift would
otherwise pass vacuously)
- Reword the PRE_PUSH_MARKER comment: bumping its version would make every
installed v1 hook read as foreign and never refresh again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- claude's declared-for-later wheelForward says the live rule in
_shouldForwardWheelToApp is the version AND the server-published
cliMouseTracking flag, so whoever wires the field up needs both
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- claude watchingLine: the lookahead keys on "Artifact" alone, so a
footer truncated mid-chip ("1 Artifact…", "1 Artifact comm…") is still
refused instead of reporting the shell beside it; comment follows
- test: both truncations return no watching label
- invariants: a chip that waits on a human never counts as watching, and
the ^ anchor is what stops the retry past the chip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- test: the complete-identity case now checks the combined
agentImageBuildArgPairs() argv on both producers, so the manual
build-agent-image.mjs path cannot drop the identity unnoticed
- both producers: GIT_IDENTITY_BUILD_ARGS carries the mirror/parity
warning its gh/az neighbour has
- the partial-identity error names CODEMAN_AGENT_IMAGE_GIT_USER_NAME and
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL; test regex follows
- wiki Docker-Cases: mention the identity variables next to the gh/az
switches
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- test: every ENV PATH= line in server.Dockerfile must start $PATH:, and
the ~/.local/bin append is pinned alongside /opt/codeman-cli/bin
- invariants + CLAUDE.md: the append-only PATH rule names ~/.local/bin too
- docker-compose.md: Settings-installed CLIs live in ~/.local on the
app-data mount; reinstall once after upgrading; hand-run npm installs
need --prefix ~/.local
- installEnv() JSDoc describes the in-container npm prefix redirect
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When Claude hands work to an ultracode workflow or background agents, it
ends its own turn and closes it with `✻ Waiting for 1 dynamic workflow to
finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when the
workers report back. The pane sits quiet with the composer up, so the idle
probe called the session idle for the whole wait. At phone width the
workflow's progress row also drops its ticking timer, so nothing on screen
changes for minutes.
A new optional registry field, `capabilities.workDetect.awaitingLine`,
names that closing row, and `_probePaneWorking()` counts it as work.
Claude renders the row once from a snapshot and never redraws it, so the
same words stay on screen after the workers finish. `isAwaitingWorkers()`
therefore tests only the newest column-0 row directly above the composer,
never the whole pane and never the PTY stream; a follow-up turn always
puts rows of its own there. The column-0 anchor also keeps an agent from
holding its own tab busy by printing the sentence.
Verified against the live Mac mini pane that reported the bug (2.1.283),
and end to end on an isolated instance: an ultracode session running a
90 s workflow at 46 columns stayed busy through the wait and the
follow-up turn, then went idle 6 s after that turn closed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a phone every inactive tab rendered transparent: grey 11px text
floating in unmarked gaps, a boxed Alt+N digit in each tab (a phone has
no Alt key), names capped at 50px so a shared `w1-` prefix was most of
what showed, and the tab that did not fit was chopped mid-word against
the connection dot. The strip looked like a row of disabled labels.
Phone block of mobile.css only:
- Every header tab is a chip, filled and bordered from the skin's
--control-* tokens, name in --text at weight 500. Written
`:where(.header) .session-tab` so it stays at (0,1,0): the per-colour
left border still wins, and sidebar layout (where the list leaves the
header) is untouched.
- The Alt+N digit is hidden in the header; inactive tabs drop their
empty .tab-actions container, which padded the chip's right side.
- Name cap 50px -> 80px, status dot 4px -> 6px, strip gap 2px -> 6px.
- Scroll-driven edge fade: a mask on the strip whose widths follow its
own inline scroll timeline (registered @property lengths), so the
clipped tab dissolves into the edge. No JS; a strip that does not
overflow gets no mask, and browsers without scroll timelines keep the
old hard edge.
The tap-zone arithmetic comment is updated for the numberless phone
tabs and the bigger dot (the required reserve drops from 38px to 36px;
the 44px min-width stays). test/mobile-tab-strip-chips.test.ts pins the
(0,1,0) selector, the top-level @property registration and the
timeline-after-shorthand order, each of which fails silently otherwise.
test/mobile/tabs.test.ts follows the new name cap.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Clicking a .md in the Files panel showed wrapped source with an Edit
pencil and no way to see it rendered, although marked + DOMPurify were
already on the page for the Response Viewer. The viewer now renders
.md/.markdown through that same pipeline (one parser, one click
delegate) with an MD pill back to source, and the plain-text view gains
Lines (CSS-counter gutter) and Wrap toggles. All three persist per device
in their own localStorage keys.
- Relative images are rebased onto the workspace-confined file-raw route
under the document's directory, built inside a <template> so no fetch
fires before the rewrite; a failed load degrades to alt text. Relative
links become a.rv-path so the existing delegate opens them in the
viewer; fragment and http(s) links are untouched.
- The rendered container carries data-i18n-skip so the translator does
not rewrite the document's prose.
- Markdown fetches the route's 10000-line ceiling; other text keeps 500.
- avif renders inline (file-content image set, file-raw MIME map), and
avif/ico printed paths open the viewer instead of tailing bytes. .md
deliberately stays with the tail viewer for printed paths.
With local echo on, committed text sits in the LocalEchoOverlay and does not
reach the PTY before Enter, so the PTY cursor that places the preview span
stays at the prompt start. The span's z-index 6 only counts inside
.xterm-helpers (its own z-index 5 stacking context), and the overlay is a
z-index 7 layer whose first line is opaque from the prompt column, so every
composition after the first one in a prompt was drawn under the overlay.
- xterm-zerolag-input: add setComposition(text) and a composition getter.
The overlay draws the composition as an underlined, aria-hidden tail after
its pending text, through the same wrapping and grow-upward layout. It is
never part of pendingText, hasPending or anything sent; clear() and
removeChar() drop it, and rerender()/refreshFont() keep it.
- terminal-ui.js: while local echo shows typed text (on, and not handed back
to PTY echo by a nav key), render and clear the preview through
setComposition. The helper span stays for local echo off, and as the
fallback when the overlay cannot place the text (no prompt found).
- Browser test against real xterm 6, the overlay bundled from its source
and styles.css: a second composition after pending text is the topmost
element after that text, and the commit lands in the overlay once. Unit
tests for setComposition in the package and for the routing in the
structure test.
- CLAUDE.md and architecture-invariants: state the preview's effective layer.
- Normalize the value shapes ExcelJS loads before formatting: Date cells are
formatted from their serial (UTC), so they no longer render as a local-time
string a day early at negative UTC offsets; rich text joins its runs,
hyperlinks show their text, error values show the error, and formula and
shared-formula results (including error results) recurse. Excel serials are
rounded to whole milliseconds so 00:05 no longer shows as 00:04.
- sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a
truncated tile with a warning instead of failing the whole preview.
- ExcelJS now parses a STORE-only archive rebuilt from exactly the entries
admitXlsx() inflated and counted, never the fetched bytes. Admission walks
local headers while JSZip reads the central directory, so overlapping
entries could show the two readers different sheets. A duplicate local
entry name is refused. The theme fallback reads the admitted entry too.
- Row and column headings take their size from the same axis math as cells.
- Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION
rules in architecture-invariants, and list .xlsx in the attachments panel
help and the `codeman attach` error text (built from the accepted list).
xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
- Observe keydown in the capture phase on terminal.element, an ancestor of
the helper textarea, so the controller sees it before xterm's own capture
listener finalizes the composition and emits the commit through onData.
Finalize on exactly the keys CompositionHelper.keydown does (every keyCode
except 20/229/16/17/18), ignoring isComposing and key as xterm does.
- Bound awaitingCommit with the same 2 s fallback as the committed phase, so
a composition whose commit never reaches onData cannot turn the next
unrelated keystroke or paste into an IME commit.
- pagehide resets the controller instead of destroying it, so a back-forward
cache restore keeps the preview working.
- Give the preview an opaque background from the terminal theme.
- Route an IME commit through the ordinary printable/paste local echo branch
and complete the commit afterwards; drop the send-on-throw fallback.
- Pin the event order with an xterm stand-in registered in the capture phase
ahead of the controller, and against real xterm in a browser test.
- CLAUDE.md: note the IME commit routing and the z-index 6 preview layer.
webview:changed carried only {action, id} and the SSE routing hint had no
webview: branch, so every connected client received it: in multi-user mode
any user saw the ids of other users' web-tab creates, edits and deletes.
The event now carries the web tab's owner (from the stored record) and is
routed to that owner plus admins. Single-user delivery is unchanged.
resolveGitHooksDir now returns a directory only when it is the repo's own
<git-common-dir>/hooks (compared on canonical paths), so a core.hooksPath
elsewhere, global or repo-local, is never written to by postinstall, while a
core.hooksPath pointing back at the repo's own .git/hooks still resolves.
The pre-push hook skips with a one-line notice when a pushed ref is not the
checked-out HEAD (tags peeled) or when git status shows uncommitted or
untracked changes under a path the checks read (src, config, scripts, test,
package.json, package-lock.json, install.sh), since the checks read the
working tree rather than the pushed commit.
Also: honest timing (~10-40s instead of ~15s), CLAUDE.md Session Safety note
on CODEMAN_SKIP_PREPUSH for another session's WIP, 14 (not 9) Playwright
tests, and a note that the browser-excludes check only sees direct imports.
npm run check:browser-excludes finds tests that import a browser driver and
asks `vitest list` whether the CI config still collects them; wired into CI.
npm install now also installs a marker-owned pre-push hook that runs the
static CI checks (~15s). Skip with CODEMAN_SKIP_PREPUSH=1; hand-written
hooks are left alone.
WebKit on iOS does not show text being composed by an IME inside the
terminal, so users type blind until it commits. Add mobile-ime-preview.js,
a visual-only controller that renders the composition in the xterm helper
layer and holds a committed chunk until local echo, parsed terminal output
or a 2s fallback shows it. Wire it into terminal-ui.js, the script order,
the build minify/hash lists and styles, with unit and wiring tests.
Claude 2.1.280 renders inline by default: no alt screen, no mouse tracking, transcript in real scrollback. The version-only gate still sent every wheel tick and touch swipe as SGR reports, which Claude ignores, so scrolling a Claude session was dead while codex (routed locally) worked. Gate forwarding on the server-recorded cliMouseTracking flag, which fullscreen mode (CLAUDE_CODE_NO_FLICKER=1) sets.
Adds a centered star call-to-action under the badge row in both the
English and Simplified Chinese READMEs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A window cut at the tail size can be smaller than the browser's buffer
while tmux still holds more. The downgrade guard reads that as "tmux has
nothing more to give", which is true of an unbounded capture only, so a
bounded window reaching it marked the session exhausted and removed Load
full history from the banner.
The bounded skip now runs first, so such a window never reaches the
exhausted path, and it no longer writes banner state: relabelling it from
the bounded payload would call a terminal holding all of a Load full
history pull "the most recent 1 MiB".
A skipped window that came back truncated cannot reach anything older
than the browser shows, and every ask costs the server a synchronous
capture-pane of the whole history (tail is applied after the capture), so
it puts the session on the 60 s cooldown. An untruncated one keeps 4 s.
_replayWouldShrinkBuffer takes optional pre-estimated rows so a megabyte
capture is not scanned twice. CLAUDE.md's Full-scrollback replay entry no
longer says Shell never pulls on ordinary scroll.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A burst of output leaves a Shell pane with about one screen of browser
scrollback, because tmux repaints the burst instead of scrolling it,
while tmux itself keeps every line. Shell declined the scroll-to-top
re-pull other modes use, and the Load full history button renders only
once a replay was truncated, so a Shell tab under 1 MiB could not
scroll back at all.
The scroll gesture now pulls ?full=1&tail=TERMINAL_TAIL_SIZE, the same
bound a tab switch loads; the route's existing tail cut marks longer
histories 'tail', so the banner still offers the unbounded pull. A
window no longer than the browser's buffer is not rewritten.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An agent that publishes an artifact arms a monitor for its comments and
ends its turn. Claude Code shows that on the footer as `1 Artifact
comment monitor`, and #473 put that chip on the list of background work,
so the session counted as watching and its idle prompt opened already
acknowledged. Unlike every other chip on the list, that monitor waits
on the user: the agent hears nothing until somebody comments.
Claude's `watchingLine` now refuses any footer that carries the chip,
through a lookahead over the whole row, so a shell running beside the
monitor cannot report the session as watching either.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is image content, so
Update-Codeman.sh discarded every npm-installed CLI (dsh, pi). In the Compose
container, POST /api/clis/:id/install now installs into ~/.local on the
persistent home mount, and ~/.local/bin is appended to the image PATH.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
The phone case picker had no way to narrow a long case list, so finding
one meant scrolling a sheet that showed about six rows at a time.
- A search field filters rows by name (every typed word must match, any
order, case-insensitive), with a "No matching cases" state. Enter picks
the case when exactly one row is left; Escape clears, then closes.
- The field is not auto-focused, so opening the picker does not raise the
keyboard. The list holds its unfiltered height while searching so the
sheet does not jump, and the input is 16px so iOS Safari does not zoom.
- Layout: the sheet padded the home-indicator inset on top of the footer
already doing so, leaving a dead band under Create New Case; the sheet
now grows to 80dvh and the list fills it instead of a separate 50vh cap.
- Opening scrolls the list (its own box, not scrollIntoView) to the
currently selected case.
Co-authored-by: Codeman maintainer <noreply@anthropic.com>
The Run bar's case picker only loaded /api/cases at page load, so folders
deleted or created on disk stayed listed until a reload. It now refetches
on open and every 5 seconds while open, repainting only when the list
changed and falling back to another case if the selected one was removed.
The Manage tab of Add Case gains a search box filtering by name or path.
Reorder arrows are disabled while a filter is active so a swap cannot
involve a hidden case.
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cleanup): keep .claude-images while a sibling session uses the same dir
cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.
The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.
Split out ahead of the exited-agent sweep for Ark0N/Codeman#446, which closes
sessions unattended and would otherwise make the loss routine.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(session): close sessions whose agent exited cleanly (#446)
Part 2 of Ark0N/Codeman#446. Part 1 records an exited agent as
SessionState.paneExit. A session whose agent the user ended with /exit is
now closed through cleanupSession(), the same path the X button takes, so
finished sessions stop piling up on the board. The lifecycle log records
the reason as "agent exited cleanly (status 0)", and the conversation stays
resumable from the Resume list.
shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts
holds the rule. It closes a session only when all of these hold:
- The exit status is an explicit numeric 0 with no signal. An absent status
is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the
row stays. A non-zero status or any signal also keeps the row, with the
exit code on the tab.
- Two authoritative pane reads agreed on that exit.
TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or
skipped read neither confirms nor resets the count.
- No start, attach or relaunch is running for the pane.
Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose
dead-pane branch revives an exited pane on purpose, and restartCli().
setPaneExit() already scopes paneExit to local mux-backed sessions, so
remote, docker and direct-PTY sessions are never closed.
planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit. That covers an agent that exited just before a reboot, before
the sweep reached it. A crashed agent's record stays eligible, like its row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(web): show "exited" on the phone overview and desktop home rail (#446)
Part 1 of Ark0N/Codeman#446 taught the tab strip and the rich rail rows
to say that a session's agent has exited. The phone overview and the
desktop home rail still said "idle", beside a green or pulsing dot.
_mobileOverviewExit() in mobile-overview.js is now the one rule for all
three surfaces, and _sidebarRichRow() uses it as well. It changes what a
row shows and leaves the row's state alone, because the state still picks
the section and the sort order. An exited row gets an "exited" pill, a
neutral dot and row accent, and a duration measured from when the server
first saw the pane dead. A pending permission prompt or question still
wins, as it does on the tab.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cleanup): close the gaps review found in the #446 sweep and image guard
Four fixes from a dual review of Ark0N/Codeman#446 part 2.
- The .claude-images guard compares canonical paths, so a sibling that
reaches the same directory through a symlink keeps it. Its comment used to
say that case only missed a deletion; it caused one.
- A detached session counts as a live sibling. DELETE ?killMux=false removes
it from the server's map while its pane keeps running, so the guard now
reads persisted records too, and exempts only sessions being killed rather
than every session in cleaningUp.
- A session being closed refuses startInteractive() and startShell(). The
/interactive route awaits listener setup before the start, and a start
that raced the close could launch a CLI in a tmux session whose record was
then deleted. A failed close clears the mark again.
- The clean-exit sweep tries each exit once, keyed by session id and the
exit's at stamp, so a close that fails is not retried and logged every
two seconds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(session): keep a clean exit that lands within 10 s of a pane start (#446)
A CLI that prints a startup error ("not logged in", a bad profile, a config
error) and exits 0 used to lose its tab, and the error with it, about 4 s
after launch. The sweep now keeps any clean exit that lands within
CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch
finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The
row stays as "exited (0)" for the user to read and close.
Verified on an isolated instance: a shell that ran `exit 0` 2 s after start
kept its row, one that exited after 13 s was closed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A single input over MAX_INPUT_LENGTH (64 KiB) was queued for reliable
delivery, refused by both transports (the WebSocket silently, POST with a
400), and never dropped: the client treated the 400 as transient, so the
frame was re-sent every 2 s forever, blocked every later input for that
session, and came back from localStorage on every reload.
- Client: a paste over the frame limit is split into in-limit frames
(never cutting a surrogate pair) delivered in seq order; over 1 MiB, or
an oversized mux write, it is refused with a toast and never queued.
- Client: the POST drain drops a frame answered 400/413; a WS error ACK
drops it too; frames over the limit persisted by an older build are
pruned on load.
- Server: the WebSocket answers an oversized sequenced frame with
{t:'ia',seq,err:'too_large',max} instead of silence (an older client
reads that as a plain ACK and drops it); the POST schema uses
MAX_INPUT_LENGTH instead of a second 100000 limit.
Verified end to end on an isolated instance: a 110 KB paste reached the
PTY byte-identical over both the WebSocket and the POST path, a poisoned
120 KB persisted frame was pruned on load, and a 2 MB paste showed the
refusal toast with nothing queued.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run
menu's "DeepSeek - add a terminal profile" button failed with
`dsh: pnpm not found on PATH` (exit 127) on the server image. The agent
image already installs pnpm for the same reason (#352). Pin pnpm@12.6.0
in the runtime-writable CLI prefix and note it in the DeepSeek doc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
An Android WebView wrapper has no browser pop-ups, so a foldable could not
show two sessions, or a session and a file, side by side. A wrapper that can
open a window of its own now exposes window.CodemanHost.openWindow(url);
detachSession, detachFilePreview and openWebviewExternal hand their URL to
it, mobile.css keeps the pop-out icon under html.host-windows, and a solo
window closes and raises itself through the host when it offers the calls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop toolbar carried two identical "minus 1 plus" instance steppers side
by side, one after Run and one after Run Shell. The second (#shellCount) is
gone for a cleaner strip.
Run Shell keeps the capability: both launch paths now read the remaining
#tabCount control through _toolbarInstanceCount(), which also makes an absent
stepper read as 1 instead of throwing. That matters because the group is
display:none on phones and tablets, and because the Run dropdown's
Terminal / Shell entry routes through runShell() too, where the visible counter
was previously ignored.
Desktop only: both steppers were already hidden under 1024px.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The phone case sheet listed every case with no way to narrow it, while the
desktop toolbar combobox has filtered for a while. The sheet now carries a
search field that runs the same matcher (filterCasePickerOptions), so both
pickers answer a query identically: every term has to appear in the option's
searchText, which already carries the name, the rendered label, the path and
the remote/docker fields.
Details worth keeping:
- The filter resets on every open. The sheet is a one-shot picker, and a
leftover query would present a truncated list as the whole one.
- No autofocus. Focusing raises the keyboard over a sheet anchored to the
bottom of the screen, so the user asks for it.
- The sheet is a third position:fixed bottom-anchored surface, so it joins the
toolbar and the accessory bar in KeyboardHandler's keyboard lift. iOS does
not shrink the layout viewport, so an unlifted sheet would sit behind the
keyboard with its own search box out of sight. resetLayout() clears the
offset unscoped, or a sheet closed while the keyboard was up would slide in
already displaced next time.
- Enter takes a single remaining match and otherwise just dismisses the
keyboard; Escape drops the filter before it closes the sheet.
- No match renders an empty state rather than a blank sheet.
- The clear button needs an explicit [hidden] rule: the UA's display:none is
specificity (0,0,0) and loses to the button's own display:flex.
- The input drops the global input:focus-visible ring, which inside an already
bordered row drew a second border a few pixels in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:49:56 +02:00
358 changed files with 70284 additions and 3227 deletions
"description":"Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
@@ -28,12 +28,15 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
CI runs all of these, so save yourself a round trip:
```bash
npm run typecheck # tsc --noEmit, strict mode
npm run typecheck # tsc --noEmit, strict mode
npm run lint
npm run format:check
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
```
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
### Tests
```bash
@@ -52,7 +55,9 @@ npm run test:all # literally everything, environmental failures included
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
- @opticon454 for four PRs in this release: the end of Android autocorrect duplicating a line (#541), a Run dropdown that fits and scrolls on any screen (#542), Git status that copes with big folders and slow shares (#543), and a nightly browser-suite workflow plus the three stale browser tests it needed (#540). Thanks also for confirming #550 on a live install.
- @JDProfresh, a first-time contributor, for four PRs: Respawn and Ralph back in Claude's Session Options (#550), removing the broken tunnel Upload URL page (#551), the wiki's Contributing page catching up with CONTRIBUTING.md (#552), and a remote-wake test that no longer pins its budget to the millisecond (#559).
- @Randalix for making opencode tabs selectable and scrollable again (#555), and for the server reporting the port it really bound, which let the test suite move off fixed ports (#556).
- @shenlvkang-collab for Default Codex model and reasoning effort settings (#546), and for the opt-in `window.CodemanHost` bridge that lets a native wrapper app pop a session or a file preview out into a window of its own (#432).
- @aakhter for `.xlsx` spreadsheets in the File Viewer (#502), parsed entirely in the browser behind admission limits that held up to round after round of review.
This is the biggest visual overhaul Codeman has had: a tile grid for driving several sessions at once, a new header, CLI logos everywhere an agent is named, lineage trees, a calmer welcome screen and optional new tab layouts. The defaults are chosen so an upgrade looks familiar: the tab strip stays Classic, the header gets the Compact stats, and the Tiles button is there to try.
**The tile grid: up to six live sessions side by side.** Click **Tiles** in the header (or press `Ctrl+Shift+G`) and the window splits into a grid of real terminals, each one a full session you can read and type in, with its own connection. Every tile has a small header naming the session, the agent (its logo) and the model it runs (`Claude on Opus 5.5`, `DeepSeek on qwen3.8-27b`, following an in-session `/model` switch), a status dot that turns red and pulses the tile's border when that session needs you, a session menu, zoom (`⤢` or `Alt+Shift+Enter`) and remove (`×`, the session keeps running). Right-click the button for a 2 / 4 / 6 count menu (remembered per device; a hover card explains both clicks). Add sessions by `Ctrl`/`Cmd`+clicking a tab, dragging a tab onto a tile or an empty cell, "Open group as tiles" in a tab group's menu, or just Run while the grid is open. Rearrange by dragging a tile by its header (onto another tile they swap, and an empty cell can sit anywhere), with `Ctrl+Shift+Arrows`, and resize with the column and row dividers; `Alt+Shift+Arrows`, `Ctrl+Tab` and `Alt+[` / `]` move the focus, and the focused tile is the session every panel follows (files, git status, respawn, subagent windows, voice, image paste). Picking a tab that is not tiled shows it on its own and one click brings the grid back; app-driven selections never collapse it. The grid survives a reload (per device, session ids only), opens and closes with a short animation (off under reduced motion), auto-zooms the focused tile when the window gets too small, shows an Attach overlay for a session that is not attached or whose agent exited, and is fully translated into 简体中文. It was built to stay fast with six busy agents: tiles paint first and load their terminals one per frame, a tile replays its history at xterm's own pace and never reads more scrollback than it keeps, the main terminal is parked (no SSE terminal stream) while tiles own the screen, and a window resize refits each tile exactly once. Desktop only (a window at least 1180px wide); the setting is App Settings → Header & Panels → Tiles, on by default on desktop and opt-in on touch tablets. The user guide is the new [Tile Grid](https://github.com/Ark0N/Codeman/wiki/Tile-Grid) wiki page.
**Split view, rebuilt on the same terminal (#560).** The split's second pane is now a `TerminalTile`, the same component every tile is: its input goes through the exactly-once queue, so a dropped link can no longer lose or double a keystroke; it reconnects on its own after a drop or a server restart; it and its PTY never disagree about size; file paths are clickable and `Ctrl+V` pastes images into it; and app shortcuts, voice and image paste follow the pane you are in. Both panes now name their agent and model above them.
**A new header.** The header stats come in three styles (App Settings → Header & Panels → Header Stats Style, per device): **Compact**, the new default, draws WS, CPU, MEM and the plan usage windows as two pills where every reading is a ring, a label and a value; **Tiles** gives each one its own box; **As before** keeps the old readout. The icon buttons beside them take the matching shape, and hover now moves the icon, never the button.
**CLI logos everywhere an agent is named (#532).** The Run menus (toolbar, phone overview picker, custom endpoints, model picker), every agent tab (header strip, rail, sidebar, phone chips, the desktop home rail, Claude's tabs included), the tile and split headers and the welcome screen now show each CLI's own logo instead of a colour dot or a two-letter pill. The marks are inline SVG, follow every skin, and a CLI you added through `clis.json` gets a plain dot.
**Lineage trees (#544).** The lines from a tab to the tabs it spawned are now one rounded tree per spawning tab, routed through the gaps between tab rows so they never cross a tab or reach the terminal. Every family is always drawn, the selected tab's family is drawn thicker and on top, and a dashed branch now means that child is working.
**Tab layouts (#538, optional).** App Settings → Appearance → Tabs → **Tab Layout** adds three opt-in arrangements next to the default **Classic** strip: **By state** (a row each for Needs you, Waiting, Working and the rest, most urgent first, flippable with State Order), **By case** (one labelled box per case) and **Ledger** (an aligned grid of equal cells). By state and By case also group the vertical rail and the sidebar. Per device; phones keep their scrolling chip row.
**A calmer welcome screen.** One primary launcher for the first agent in your catalog (Claude Code on a stock install, the next agent if it is disabled) with its real logo, every other CLI as a slim pill under it, and Cloudflare Tunnel as a quiet link above its QR. The toolbar's "+" and case gear moved into the case picker as "New or link a case…" and "Case settings…" rows, and the duplicate instance stepper is gone (#428).
**Every session knows its model.** Sessions publish the model they run (`displayModel` on the session state): a custom endpoint's model id, else what the running CLI itself reports (Claude's statusLine, codex's, pi's and opencode's footers, the dsh status line), else the model its config pins (a DeepSeek route) or the one it was launched with. It is persisted, follows `/model` switches, and is stripped of control characters and capped.
**Idle detection for every agent.** OpenCode, Gemini, Pi and OMP turns now end: their composer bars and spinners are read so a session goes idle when the agent is done instead of spinning "working" forever, codex 0.162's new footer row no longer hides its model or its background-terminal row, and a freshly started or re-attached agent pane now announces its idle to open pages (it used to stay `busy` until a reload). A pane prompted within its first seconds is never settled idle at launch, so send-and-wait cannot resolve before the turn starts.
**Spreadsheets in the File Viewer (#502).**`.xlsx` files open as a read-only grid with sheet tabs, number formats, merged cells and colours, from the Files panel, attachments or a path an agent prints. They are parsed entirely in your browser in a worker (up to 10 MB) behind admission limits on everything the parser would expand. `.xls` and `.ods` stay download-only, and `file-content` now reports `type: 'spreadsheet'` for `.xlsx`.
**Default Codex model and reasoning effort (#546).** App Settings has a Default Codex model and a Default Codex reasoning effort, applied to new local Codex sessions (Run menu, Resume and the HTTP API) unless the launch names its own; custom endpoints, Docker and remote sessions keep their own settings.
**Pop-out windows for native wrapper apps (#432).** A native wrapper (for example an Android app on a foldable) can pop a session, a file preview or a web tab out into a window of its own beside the dashboard through an opt-in, experimental `window.CodemanHost` bridge. Browsers behave exactly as before.
**Git status for big folders and slow shares (#543).** Two per-device settings under Settings → Bottom bar set how many repositories it lists (up to 50, default 12) and how long one git command may take (5 to 120 s, now 30 s by default), and a repository git cannot read is listed with the reason and counted as `? N` in the indicator instead of silently disappearing.
**Behaviour change: `Ctrl+W` no longer closes a session.** It is delete-word in every shell and agent CLI, and muscle memory used to kill a session (its pane and CLI, with no confirm) mid-sentence. Close Session has no default key now; bind one in App Settings → Shortcuts if you want it.
**Removed: the tunnel Upload URL page (#551).** Since the response envelope change it reported "Saved: undefined" and listed nothing. Use the in-app image paste instead. `POST /api/screenshots`, `GET /api/screenshots` and `GET /api/screenshots/:name` (and their `/api/v1` aliases) still work unchanged but log a one-time deprecation warning and will be removed in a future major release.
**Fixes.** Android keyboards that autocorrect as you type (SwiftKey, Gboard) no longer duplicate the line in the prompt: the corrected word reaches the session exactly once, including when Enter arrives in the same keyboard transaction (#541). opencode tabs: a drag selects text again (so copy-on-select works and `Ctrl+C` copies instead of closing opencode), and the wheel and touch swipes page through opencode's conversation (#555). The Run dropdown no longer runs off the top of the screen: with many CLIs, endpoints and saved URLs it fits between the header and the toolbar, follows the on-screen keyboard and the iPhone safe areas, and scrolls (#542). Claude sessions show the Respawn and Ralph / Todo tabs and the auto-resume toggle in Session Options again, hidden since 1.33.0 (#550, fixes #549). The terminal refits when only its box changes size (a header that grows with the state rows or the lineage gutter used to leave the bottom rows clipped behind the toolbar until a tab switch). A device with any shortcut override no longer gets every App Settings save rejected while the toast still said "Settings saved". Image paste and dictation land in the session they started in. App Settings search finds Split and Tiles by what they do. The Run button family, the Help modal, the shortcut overlay leftovers and the exited-agent tab badge are translated into Chinese.
**For contributors.** The server reports the port it actually bound (`boundPort`), the tests that shared fixed ports bind ephemeral ones, and a static guard keeps new fixed ports out, so two test runs on one machine no longer collide (#556). A nightly (and on-demand) Playwright browser-suite workflow runs the suites the CI gate cannot, informational and never a gate (#540). The wiki's Contributing page says `npm test` is the CI gate (#552).
**A final review before shipping.** A last adversarially verified review of the whole release fixed these in the tile view: an image dropped on a tile uploads to that tile's session instead of navigating the browser away; a Claude session started into the grid keeps its welcome banner and transcript; a tile refresh never blanks the screen while it waits; a remote close or a reconnect no longer moves your keyboard into another session's tile; dictation and the phone keyboard's Path and Clear keys reach the focused tile or split pane; each tile caps its live-output backlog and recovers dropped output with one bounded refresh; Redraw on a tile forces the resize it reports; popping out the last tile leaves no frozen view; and "Open group as tiles" no longer pulls an open split into the grid. Also from that review: the By case layout stays one scrolling row on 600 to 767px tablets, the needs-you pulse animates opacity only, a codex session on the ultra effort shows its model, codex's launch defaults are CLI registry data instead of an id check, `npm run build` checks its dependencies before it deletes anything, and the release's new strings (case picker rows, Git status settings, toasts, tile and spreadsheet texts, the Redraw toasts) have zh-CN translations.
**Fixes applied while landing.** Tiles and the split's Pane B got the same two fixes the main terminal got from contributors: opencode's wheel paging and click reports (#555), and the Android keyboard handling that stops autocorrect duplicating a line (#541). Android: a word composed right before Enter in the same keyboard transaction was sent twice; it now arrives once (#541). Codex: App Settings refuses a Default Codex model the server would reject instead of failing the whole save behind a "Settings saved" toast, and the two Codex rows stack under their labels on phones (#546). Run dropdown: its height follows the on-screen keyboard and both iPhone safe areas, a long custom-endpoint label no longer adds a horizontal scrollbar, and the open menu sits above the keyboard accessory bar (#542). Git status: a lone repository git cannot read is no longer shown as clean and empty, a repeated `timeout` query parameter no longer answers 500, and the timeout field accepts any whole number of seconds (#543). Spreadsheets: cells clipped to nothing at the edge of a very large sheet no longer grow its scroll area, and the preview's fixed texts have zh-CN translations (#502). Pop-out windows: the bridge refuses to pop out without a window channel (the tab could never re-dock), the tab menu follows the host-aware default, and Close window works inside a host window (#432). Every deprecated `/api/screenshots` route now logs its warning, pinned per route (#551). Across the new tab layouts and header styles: lineage lines run between the state labels and the tabs and never through a case box, an open Tiles or Split button stays highlighted in the boxed header styles, a phone keeps the active chip in view when it changes state band, and the Tab Layout and Header Stats Style settings are translated.
## 1.35.0
### Minor Changes
- 6f88e40: ### Thanks
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
## 1.34.0
### Minor Changes
- 6aecc3b: ### Thanks
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
**Per-session Claude model (#514) and Codex reasoning effort (#515).**`POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
## 1.33.3
### Patch Changes
- f776ad8: ### Thanks
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
## 1.33.2
### Patch Changes
- e439cf0: ### Thanks
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
**Contributor tooling (#500).**`npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
## 1.33.1
### Patch Changes
- ### Thanks
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
Thanks @opticon454.
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
@@ -381,6 +385,14 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
### Tile Grid
<p align="center">
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
</p>
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session; the grid is remembered for next time. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
### Persistent Sessions
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
@@ -67,6 +67,27 @@ two volumes are removed, by name within this Compose project; any volume a
`docker-compose.override.yml` adds is left alone, and application data and
case workspaces are host bind mounts, never touched either way.
## Git commit identity
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
```sh
GIT_USER_NAME='Your Name'
GIT_USER_EMAIL='you@example.com'
```
Compose passes the values to the Codeman server build, and to the server process
when it builds Docker-case agent images. Both images write the pair to Git's
system configuration during their build, so commits retain the same identity
after a container or agent image is recreated. Set both values together; an
image build with only one value fails rather than using a partial identity. An
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
the server image's system-level default.
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
server container, then recreate any Docker cases that should use it.
## Private repositories (GitHub and Azure DevOps)
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning |
|---|---|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
@@ -461,6 +491,32 @@ also pure decoration: it confers no permission, and a child is unaffected by its
parent exiting. It appears on session state as `parentSessionId` (absent when
unresolved) and survives a server restart.
## Session model (`displayModel`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
session runs as far as the server knows it, for the web UI's session headers:
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
Between `statusline` and `screen` the newest report wins. The field is absent when no
model is known (a shell, a CLI that reports none and was launched without one). `model`
is display text from a pane or a CLI report: control characters are stripped and it is at
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
persisted and restored after a server restart until the next report replaces it; a
`config` value is read again at every pane start, attach and relaunch instead.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
@@ -691,6 +747,46 @@ normal `caseName`/`mode`/etc. body)
jarring than a full relaunch, and folding it into the one-shot path is
separate work — see `docs/custom-model-endpoints-plan.md`).
## Creating a case in a custom folder
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
## Git status
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only):
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
## CLI management
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
@@ -704,6 +800,45 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
## MCP server sync
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
## Webhook notifications
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
## Diagnostics
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status``ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -56,25 +62,45 @@ interface CliEntry {
### Regexes that come from config
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and`capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`,`capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 8, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), codex's ` <model> <effort> · ` footer (its last row, or the row above codex 0.162's indented hint row, so a two-row window), and opencode's composer agent row (`┃ Build <model> <provider>`, directly above the box's `╹` edge, eight rows because its home screen puts up to five rows of its own chrome below it). opencode's field is the model AND the provider, since only colour separates them on that row; the pattern takes the LAST such row in the window, so a composer-shaped row the agent prints higher up cannot stand in for it. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
so a pane waiting for its own background work never raises an alert a human cannot answer.
Group 1 is the label, and a CLI that declares no pattern reports no background work.
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
to comment on a page the agent published, so Claude's pattern refuses any footer that
carries it, and the idle alert goes out as usual.
Two CLIs declare such a row today, and they put it in different places. Claude writes its
chip on the last row of the screen, so it keeps the default one-row window and anchors on
the `·` its footer joins items with. Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
puts the row third from the bottom once the status line and the composer are counted, so its
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
against live panes rather than read out of a binary, which is the standard for adding a
third.
puts the row third from the bottom once the status line and the composer are counted, and
fourth on codex 0.162+ at rest, where a `← for agents · ? for shortcuts` hint row sits under
the status line (it disappears while a prompt is typed). So its entry declares
`watchingLines: 4` and matches that row end to end. Both were measured against live panes
rather than read out of a binary, which is the standard for adding a third.
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
to wait for workers the CLI will resume from by itself. When background agents or an
ultracode workflow are still running at turn end, Claude closes the turn with
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
the words are still on screen after the workers report back and the follow-up turn ends.
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
tests only the first row that starts in column 0, which is the newest transcript row. Claude
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
agent from holding its own session busy.
That label is the one value in the registry that an AGENT can influence, because it comes off
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
@@ -102,6 +128,10 @@ sure its row is one the agent cannot write.
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
## The newline chord
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
## Arg-template safety
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
@@ -233,6 +263,14 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
## See also
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
@@ -83,6 +83,8 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
@@ -6,6 +6,8 @@ For the Compose configuration, environment settings, storage migration, and macv
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
@@ -158,7 +158,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
| `GET /api/away-digest` | Aggregate only owned sessions/events |
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Screenshots `/api/screenshots`(deprecated)| Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
> **Update 2026-10-09 (working/idle shipped, from measured data):** the registry entry now declares `capabilities.workDetect` for opencode, measured on a live opencode 1.3.0 pane (pane captures every 250-300 ms through real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream). Every composer row starts with a `┃` bar, which arms the shared screen-probed idle check; a running turn puts an 8-cell knight-rider spinner (`⬝■■■■■■⬝ esc interrupt`) at the head of the footer row, redrawn about every 40 ms, and `[⬝■]{8}` is the working line. The label is not the anchor: tmux ships `esc` and `interrupt` as separate words joined by cursor moves, and below about 45 columns the footer wraps it. At rest the TUI is silent (no cursor or timer redraws), and a pending permission prompt replaces the composer and stops the spinner, so it reads as idle. Before this, a turn that ran a tool latched the session `busy` for good (the tool row's braille spinner tripped the generic spinner detector, and nothing ever armed the idle check). Token parsing and the ANSI content filter remain deferred.
### Goal
Detect OpenCode's state from terminal output (idle, working, ready).
@@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
- 40ms SGR coalescing: never send per-event writes to the server.
- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three.
- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`.
- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified.
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
@@ -354,8 +354,9 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
routes, attachments are intentionally **cross‑workspace** — so the effective gate
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
realpath containment.
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and
other text), not realpath containment.
Two registration paths, with **different trust**:
@@ -532,6 +533,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
---
## 10c. Webhook notifications (outbound channel)
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
@@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
⚠️ **Corrected 2026-09-16:**`opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged.
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
### 2.6 Helpers that already exist and should be reused
@@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
@@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
6. Repeat 1 and 2 in an`opencode` or `shell` tab using Shift+drag to select.
6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16).
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
| `Ctrl+W` | Kill the active session. |
| `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
use it to delete the previous word. **Close Session** has no key by default; close a session
from its tab, or bind a key to it in App Settings → Shortcuts.
## Terminal
| Shortcut | Action |
@@ -36,6 +39,22 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
@@ -50,20 +50,37 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
### Header & Panels
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
committed and pushed. Click it for the Git window; see
[Working With Files](Working-With-Files#git-changes). **Git status: group files
by folder** (per device, on by default) shows changed files under collapsed folders in that
window; off lists every file by its full path. **Git status: max repositories** (per device,
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
long one git command may run before that repository is reported as unreadable; raise it for
repositories on a slow network share.
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go.
resizable panes plus their divider have nowhere to go.**Tiles** is desktop-only the same
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
[Tile Grid](Tile-Grid).
This section also holds background-agent tracking, including whether to track agents for
every session or only the active tab.
@@ -78,22 +95,33 @@ every session or only the active tab.
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tab Layout | *Classic* (default): the single list as before. *By state*: a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. See [The Dashboard](The-Dashboard#tab-layouts). |
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. |
### Models
Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
and the switch compose into one model choice, so there is no separate "which one wins"
question.
Claude model cards, the 1M context window switch, the thinking effort segment and the
advisor segment. The cards and the switch compose into one model choice, so there is no
separate "which one wins" question.
Model and effort are both**soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
inside a session override them at any time.
Model, effort and advisor are all**soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
`/effort` and `/advisor`inside a session override them at any time.
**Advisor** gives new Claude sessions Claude Code's
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
consults before committing to an approach, when an error keeps coming back, and before it
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
which costs less than running the stronger model all the time. **Default** leaves it to
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
attached.
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
@@ -110,13 +138,17 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.``_``-``/` only. |
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
Master toggle, browser notifications, push subscription, audio alerts, and the idle
threshold that decides when a quiet session counts as needing you. See
Master toggle, browser notifications, push subscription, audio alerts, the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
### Voice
@@ -133,8 +165,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
**Users** administration entry is injected here.
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
## Session Options
@@ -169,6 +203,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back.**Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
per device, so a sidebar on your desktop does not force one onto your phone.
## Tab layouts
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
| **By state** | Groups the tabs by what each session needs from you (below). |
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
| **Classic** (default) | The single list in tab order, as before. |
**By state** groups the tabs like this, most urgent on top:
| Connection dot | Always on | SSE connection health. Green is connected. |
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
| CPU / MEM bars | On | Server resource use. |
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
| File Viewer | On | Toggles the file browser panel. |
| Settings gear | Always on | App Settings. |
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
@@ -118,12 +157,33 @@ The right side of the header. Almost all of these are off until you enable them
| Cron ⏰ | Off | Scheduled jobs. |
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
| Tiles | On, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. |
### Header Stats Style
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
| **Tiles** | One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
| **Compact** | The default. Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
Hiding System Stats or Plan Usage still hides them in every style.
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
## Bottom bar
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
Panels → Bottom bar**. Click it for the Git window. See
[Working With Files](Working-With-Files#git-changes).
## Connection state
The dot in the header is the quick read. Two louder surfaces exist because a cached page
@@ -151,11 +211,16 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
and automatic output recovery stay within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
always local scrollback. Other CLIs scroll locally.
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. OpenCode's wheel and swipes page its own conversation
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
too. Other CLIs scroll locally.
- **Selection copy.**`Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.**`Shift+drag` starts a selection even in a pane
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
| name | Double-click to rename the session. |
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. OpenCode shows the model and its provider together (`Big Pickle OpenCode Zen`), exactly as its own composer does. |
| `⋯` | The session menu: options, open in a new window, close the session. |
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
voice and image paste. Tabs of tiled sessions carry a small underline.
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
one on its own until the window is big enough again.
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
whose agent exited inside its pane says so instead; close that session from `⋯`.
## Moving tiles
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
header focuses its tile when you click it, not when you press it.
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
down: into the empty slot if that is the place, else trading places with the tile there. It
keeps the focus.
A moved tile takes the size of the place it lands in: column widths and row heights stay
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
the empty slot included, is saved with the grid and comes back on reload.
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
| PDF and Office documents | Converted for preview when a converter is available. |
| Anything else | Download. |
@@ -85,8 +87,9 @@ File paths in a session are links. That works in two places:
render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
the tail viewer instead, which follows a file that is still being written.
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
viewer instead, which follows a file that is still being written.
Paths **outside** the session's workspace work too, which matters because that is where most
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
@@ -161,6 +164,44 @@ HEIC images from an iPhone are converted to JPEG on the way in.
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
surface as an artifact attachment rather than a path you have to go and find.
## Git changes
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed.
Click it for a draggable window, in the style of the File Viewer:
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
not on remote"), which counts every commit on no remote rather than showing a green tick.
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
list of full paths instead.
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
coloured. Staged files show index versus last commit, not-staged files show working tree
versus index, untracked files show as all additions and deleted files as all removals.
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
note instead, and a diff over 400 KB is cut short.
- A session folder that holds several projects gets one collapsible section per repository
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
when there are more). A repository git could not read, typically a timeout on a slow network
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
in your home folder) is ignored.
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
workspace is skipped even from a local session (a container can write there, and git would run
that repository's own configuration on the host). The
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
## Gotchas
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
**Per-session Claude model (#514) and Codex reasoning effort (#515).**`POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
---
@@ -202,8 +202,9 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|--------|---------|-------------|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
### Backspace handling
@@ -217,6 +218,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
### IME composition
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
```
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
### Flushed text
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
@@ -242,7 +256,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Method | Description |
|--------|-------------|
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
### Prompt
@@ -258,7 +272,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Property | Type | Description |
|----------|------|-------------|
| `pendingText` | `string` | Unacknowledged text (read-only) |
| `hasPending` | `boolean` | `true` if the overlay has any content |
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
"description":"Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"description":"Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
process.exit(1);
}
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
if(!listingMatchesTree(ciFiles,testFiles)){
constsample=[...ciFiles].slice(0,3).join(', ');
console.error(
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
);
process.exit(1);
}
constleaked=findLeaks(browserTests,ciFiles);
if(leaked.length>0){
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
console.error(
palette.err(
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.