Commit Graph
100 Commits
Author SHA1 Message Date
Codeman maintainer 8392854619 feat(sessions): publish the model each session runs (displayModel)
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>
2026-10-07 13:31:59 +02:00
Codeman maintainer d044406f8f feat(web): show each CLI's logo in the Run menus instead of a colour dot
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)
2026-10-07 11:48:47 +02:00
Codeman maintainer 218b03ceb7 test(tiles): the load-queue test drives TerminalTile on the shared fakes
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>
2026-10-07 09:40:19 +02:00
Codeman maintainer 8ce2e5acbb test(split): TerminalTile's socket, xterm and fit fakes live in test/mocks
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>
2026-10-07 09:39:14 +02:00
Codeman maintainer 4bfe239083 docs(tiles): each SSE doc comment sits above its own function
_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>
2026-10-07 09:39:07 +02:00
Codeman maintainer 9f9bdbb671 docs(tiles): rewrap removeTile's doc comment
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>
2026-10-07 09:37:04 +02:00
Codeman maintainer a07c663ca9 test(tiles): the grid harness finds a tile's element, and serves the pure helpers
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>
2026-10-07 09:37:04 +02:00
Codeman maintainer 4b2e6a7c83 docs(split): comments say what TerminalTile does now
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>
2026-10-07 09:37:04 +02:00
Codeman maintainer 17eea3c230 test(tiles): the connect() static guard's end anchor resolves again
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>
2026-10-07 09:37:04 +02:00
Codeman maintainer c101b70678 refactor(tiles): each tile's zoom state is painted by _syncTileZoom
_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>
2026-10-07 09:37:04 +02:00
Codeman maintainer b9ce850a2c refactor(tiles): the picker's position comes from its stylesheet alone
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>
2026-10-07 09:37:04 +02:00
Codeman maintainer fbab0ffbf8 style(tiles): one rule for the grid's two accent buttons, one accent token
.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>
2026-10-07 09:37:04 +02:00
Codeman maintainer e6eb3dd849 style(tiles): no CSS hides dividers and slots that a zoom never leaves
`.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>
2026-10-07 09:37:03 +02:00
Codeman maintainer c54867d9f0 refactor(tiles): _tileGridLimit measures the terminal area itself
_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>
2026-10-07 09:37:03 +02:00
Codeman maintainer 0746cffe6d refactor(tiles): the layout helpers read the minimum tile size directly
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer 6cbaf3f7b6 refactor(tiles): Ctrl+Tab and Alt+[ / ] cycle tiles through one helper
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer be7328c8eb refactor(tiles): the picker's Open and "Open group as tiles" share _replaceTileGrid
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer 1202e9baa2 fix(tiles): "Open group as tiles" keeps the focused session focused
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer a24b548389 refactor(tiles): one helper drops a tile's queued loads and destroys it
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer 6af38abc76 refactor(tiles): removeTile has no auto option, its refocus is always the app's
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer 55b526bc1e docs(tiles): comments say 6 tiles and name the zoom button
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer 4558536676 refactor(tiles): the picker's outside-click close loses its left-click leftovers
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer b965c3d346 refactor(split): a tile's Ctrl+C copies through the primary pane's copy helpers
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>
2026-10-07 09:37:03 +02:00
Codeman maintainer ec5e4aa3e1 perf(tiles): no snapshot of the session the grid parks when it becomes a tile
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>
2026-10-07 08:57:42 +02:00
Codeman maintainer 7990249e2d docs(tiles): the replay pace, one refit per resize, the SSE filter, the line bound
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>
2026-10-07 08:46:08 +02:00
Codeman maintainer 6d72b38db4 perf(capture): a grid tile's full capture reads no more history than it keeps
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>
2026-10-07 07:44:56 +02:00
Codeman maintainer 50e0d22def perf(tiles): no SSE terminal stream for the focused tile while tiles own the terminal
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>
2026-10-07 07:11:08 +02:00
Codeman maintainer d7f6047529 fix(tiles): a tile focus no longer leaves a glow listener on its tab
_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>
2026-10-07 06:57:54 +02:00
Codeman maintainer 02387b5b16 perf(i18n): an xterm row record is decided by its rows container, once
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>
2026-10-07 06:45:16 +02:00
Codeman maintainer 409fd658f2 perf(resize): a window resize fits each grid tile once, not twice
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>
2026-10-07 06:30:09 +02:00
Codeman maintainer 5650be5200 perf(tiles): replay a capture at xterm's own pace, not one slice a frame
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>
2026-10-07 06:00:41 +02:00
Codeman maintainer 00440c02e1 docs(tiles): no + in the tile header, owner decision 9
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>
2026-10-07 02:49:12 +02:00
Codeman maintainer caf5248e22 feat(tiles): no + in the tile header (owner decision 9)
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>
2026-10-07 02:48:40 +02:00
Codeman maintainer 5da25f782b feat(i18n): the Run button family, and the Help modal and shortcut overlay leftovers, in Chinese
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>
2026-10-07 02:11:01 +02:00
Codeman maintainer 66da91f78b feat(i18n): the tab's exited-agent badge in Chinese (zh-CN)
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>
2026-10-07 02:07:27 +02:00
Codeman maintainer dfb9f32e23 fix(tiles): a tile's + menu items get their disabled reason in Chinese
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>
2026-10-07 01:20:22 +02:00
Codeman maintainer c848e7cf27 feat(i18n): the tile grid in Chinese (zh-CN)
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>
2026-10-07 01:15:43 +02:00
Codeman maintainer 6c5b4a7a25 fix(tiles): a translated tile label is not rewritten on every refresh
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>
2026-10-07 01:09:35 +02:00
Codeman maintainer be04d3e5e0 docs(tiles): the wiki's Ctrl/Cmd+click entry says what opens with the grid closed
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>
2026-10-07 00:10:38 +02:00
Codeman maintainer dd01ea9927 fix(settings): App Settings search finds Split and Tiles by what they do
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>
2026-10-06 23:28:47 +02:00
Codeman maintainer 39d87e363f style(tiles): bigger tile header buttons, the app header's icon size
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>
2026-10-06 23:27:08 +02:00
Codeman maintainer b7fafb1c16 feat(tiles): the Tiles button opens the grid at once; the picker is on right-click
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>
2026-10-06 23:23:55 +02:00
Codeman maintainer d2e72143e8 feat(tiles): the grid holds at most 6 tiles (owner decision 7)
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>
2026-10-06 23:18:29 +02:00
Codeman maintainer 21beacf700 perf(i18n): skip a whole mutation inside a skipped surface, not each node
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>
2026-10-06 21:29:36 +02:00
Codeman maintainer 0d1b91188d docs(tiles): Ctrl+Shift+G inert while the Tiles setting is off is the owner's decision
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>
2026-10-06 20:47:13 +02:00
Codeman maintainer fa9d5879b3 fix(tiles): a tile that joined before its pane existed resends its size
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>
2026-10-06 19:46:04 +02:00
Codeman maintainer f1e5b82ecc docs(tiles): the tile grid in CLAUDE.md, the invariants, the wiki and the Help modal
- 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>
2026-10-06 19:34:25 +02:00
Codeman maintainer c206d10e3a feat(tiles): sessions Run from this tab join the open grid; + offers a new session in the tile's case
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>
2026-10-06 19:31:16 +02:00
Codeman maintainer 5a58d272ea feat(tiles): the per-device Tiles setting, and Ctrl+Shift+G follows it
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>
2026-10-06 19:28:44 +02:00
Codeman maintainer 6bb16fdad6 feat(tiles): the grid survives a reload (per device, ids only)
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>
2026-10-06 19:26:58 +02:00
Codeman maintainer d331db1141 fix(tiles): Attach reads the response envelope; an exited agent gets no Attach
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>
2026-10-06 18:09:18 +02:00
Codeman maintainer dbff114dda feat(tiles): drag a tab onto a tile, Ctrl/Cmd+click a tab, "Open group as tiles"
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>
2026-10-06 18:02:55 +02:00
Codeman maintainer 65e8271fbe feat(tiles): draggable column and row dividers
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>
2026-10-06 17:59:16 +02:00
Codeman maintainer b4618bb853 feat(tiles): Tiles header button with a session picker, and a tile's + menu
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>
2026-10-06 17:55:22 +02:00
Codeman maintainer de1b48a63f feat(tiles): Attach overlay for a tile whose session is not attached or has exited
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>
2026-10-06 17:51:47 +02:00
Codeman maintainer 6fedbd1b09 feat(tiles): zoom a tile (button, Alt+Shift+Enter), and auto-zoom when the window is too small
⤢ 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>
2026-10-06 17:49:23 +02:00
Codeman maintainer 83d0caa209 feat(tiles): tile header (status dot, name, menu, remove) and the in-tiles tab marker
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>
2026-10-06 17:45:28 +02:00
Codeman maintainer 13b2989886 test(tiles): park-guards runs on the shared grid harness
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>
2026-10-06 17:41:00 +02:00
Codeman maintainer 88447e6c2b fix(tiles): no black band under a tile's last row
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>
2026-10-06 16:36:06 +02:00
Codeman maintainer 6adf750c50 feat(tiles): tile chords in the shortcut registry, and the grid never beside a split
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>
2026-10-06 16:31:29 +02:00
Codeman maintainer 1a04a75c3d feat(tiles): selections with the grid open focus tiles, and never collapse it by themselves
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>
2026-10-06 16:24:52 +02:00
Codeman maintainer 4341d3dca8 feat(tiles): the grid controller, and the main terminal parked while it is open
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>
2026-10-06 16:20:05 +02:00
Codeman maintainer fca7acd05f feat(tiles): one load queue for every capture a grid tile fetches
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>
2026-10-06 16:05:56 +02:00
Codeman maintainer 59eb509d47 feat(tiles): pure layout and state helpers for the tile grid
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>
2026-10-06 16:00:35 +02:00
Codeman maintainer 526d396492 refactor(select): extract the deferred panel refresh into _refreshSessionPanels
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>
2026-10-06 15:58:20 +02:00
Codeman maintainer bcdccd14c4 fix(shortcuts): Ctrl+W no longer closes a session
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>
2026-10-06 15:46:49 +02:00
Codeman maintainer 9e032bdc3e feat(split): Pane B reconnects as soon as the server is back
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>
2026-10-06 11:03:09 +02:00
Codeman maintainer 497711a05e docs: Pane B is a TerminalTile (reconnect, exactly-once input, focus)
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>
2026-10-06 10:46:33 +02:00
Codeman maintainer fe9b209f67 feat(split): terminal shortcuts, voice and paste follow the focused pane
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>
2026-10-06 10:45:16 +02:00
Codeman maintainer fbd69e62aa feat(split): clickable file paths and image paste in Pane B
- 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>
2026-10-06 10:20:25 +02:00
Codeman maintainer a54ad81684 fix(split): Pane B and its PTY never disagree about size (#464)
- 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>
2026-10-06 10:07:40 +02:00
Codeman maintainer 0ec17633ba feat(split): Pane B reconnects after a drop instead of staying dead
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>
2026-10-06 09:54:57 +02:00
Codeman maintainer 5e3dbf2057 feat(split): Pane B input goes through the exactly-once queue
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>
2026-10-06 09:41:10 +02:00
Codeman maintainer d1bbb4cc26 refactor(split): move the pane class into terminal-tile.js as TerminalTile
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>
2026-10-06 09:28:08 +02:00
Codeman maintainer e2f56dc077 fix(input): image paste and dictation land in the session they started in
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>
2026-10-06 09:09:51 +02:00
Codeman maintainer ead3d34411 refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
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>
2026-10-06 08:55:05 +02:00
Codeman maintainer f1537a7887 docs: tile grid design spec (two-PR plan: TerminalTile foundation, then the grid)
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>
2026-10-06 08:35:14 +02:00
Codeman maintainer ac94f339ac chore: version packages (1.35.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:01:26 +02:00
Codeman maintainer 88f5a43a9f fix(cases): bounded path probe landing fixes (#516)
- 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>
2026-10-05 20:00:30 +02:00
Codeman maintainer aca23aa404 chore: changeset wording, the in-flight rename repaint is #526's own 2026-10-05 19:53:10 +02:00
Codeman maintainer f16f294576 chore: add #516 to the landing changeset 2026-10-05 19:53:03 +02:00
Codeman maintainer 737a2527d6 fix(tabs): report an edit dropped behind an in-flight save, respect the group cap (#525 landing)
- 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>
2026-10-05 19:51:59 +02:00
Codeman maintainer 6f88e40b77 chore: changeset for the #525, #526, #534, #535, #536, #537 landing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 2c38e77f8a fix(git-status): landing fixes (#537)
- 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>
2026-10-05 19:51:59 +02:00
Codeman maintainer cf26853390 fix(doctor): Diagnostics landing fixes (#536)
- 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>
2026-10-05 19:51:59 +02:00
Codeman maintainer 192a5994e0 fix(cases): custom-folder create landing fixes (#535)
- 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>
2026-10-05 19:51:59 +02:00
Codeman maintainer 566365e127 test(tabs): static CI guard that no rail or sidebar clamp out-ranks the rename unclamp (#534 landing)
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>
2026-10-05 19:51:59 +02:00
Codeman maintainer ff94637718 Merge pull request #516 from aakhter/pr/bounded-path-probe
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
2026-10-05 19:51:43 +02:00
Codeman maintainer 2063d15c20 Merge pull request #537 from opticon454/feat/git-status-indicator
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
2026-10-05 19:51:43 +02:00
Codeman maintainer fed3a0897a Merge pull request #536 from opticon454/feat/doctor-in-settings
feat(settings): codeman doctor in Settings → System → Diagnostics

# Conflicts:
#	config/test-suites.ts
2026-10-05 19:51:42 +02:00
Codeman maintainer d9c760609f Merge pull request #535 from opticon454/feat/case-custom-path
feat(cases): create a new case in a custom folder
2026-10-05 19:51:41 +02:00
Codeman maintainer 74e8015783 Merge pull request #534 from opticon454/fix/rail-rename-unclamp
fix(rail): keep the inline rename editor unclamped in the detailed tab rail

# Conflicts:
#	src/web/public/styles.css
#	test/inline-rename.test.ts
2026-10-05 19:51:41 +02:00
Codeman maintainer fea5626efc Merge pull request #526 from aakhter/pr/grouped-rail-rename-fixes
fix(tabs): grouped rail interaction fixes for inline rename
2026-10-05 19:51:40 +02:00
Codeman maintainer bd109d3b16 Merge pull request #525 from aakhter/pr/grouped-rail-edit
feat(tabs): edit groups in the vertical rail
2026-10-05 19:51:40 +02:00
Codeman maintainer ffaa5ee80c chore: version packages (1.34.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:39:41 +02:00
Codeman maintainer 6aecc3b858 chore: changeset for the 1.34.0 landing (#499, #514, #515, #517, #519, #520, #521, #522, #523, #524, #530, #531)
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>
2026-10-04 23:53:56 +02:00
Codeman maintainer 470cf79776 fix(terminal): let a composition-only overlay follow the prompt, repaint it on removeChar, document the API (#499 review)
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>
2026-10-04 23:52:41 +02:00
Codeman maintainer 06c4c7da16 fix(sessions): scope the launch model to claude and pin it with the advisor (#514, #515, #530 landing)
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>
2026-10-04 23:52:41 +02:00
Codeman maintainer 7917273188 fix(tabs): collapsed-header alerts, quieter layout reads, tree key and touch fixes (#517, #519 review)
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>
2026-10-04 23:52:41 +02:00
Codeman maintainer b451b3851e fix(mcp): no file text in sync errors, follow relocated config dirs, docs and Settings polish (#521 review)
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>
2026-10-04 23:52:41 +02:00