Three more ways into the grid:
- Drag a session tab from the strip onto a tile: a session not yet tiled
replaces that tile in place (the replaced session keeps running); one
already tiled swaps places with it. A layout that is not full (3 tiles in a
2x2, 5 in a 3x2) shows its empty cells as slots, and a tab dropped on one
joins the grid there. Tiles and slots handle the drag in the capture phase
and stop it, because its payload is the session id as text and xterm's
helper textarea would type it into the PTY; a drag that is not a tab (a
file) is left alone. The dropped session takes focus (a human selection).
A sorted or grouped rail does not offer tab dragging, so neither does this.
- Ctrl/Cmd+click on a tab puts that session in the grid and focuses it,
opening the grid on what the Tiles toggle would bring back if it was
closed; on a window too narrow for the grid it stays an ordinary click.
- "Open group as tiles" in the grouped rail's group menu makes the group's
live sessions (as many as the window fits) the grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The grid now places every tile explicitly (grid-column / grid-row, reading
order) with a 6px divider track between columns and between rows, instead of
relying on DOM order and a gap. Track sizes are fractions (grid-template fr
values) that reset to equal whenever the column or row count changes.
Dragging a divider trades size between the two tracks either side, each kept
at the minimum tile size (the pure dragTrackFractions in constants.js, always
computed from the fractions the drag started with, so it cannot drift). The
affected tiles reflow locally at most once per animation frame, with no PTY
resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles
in other tracks hear nothing. Pointer capture keeps the drag on the divider,
the body locks the resize cursor and text selection for its duration, and
closing the grid or removing a tile mid-drag tears it down, as the split's
divider does. A zoomed grid shows no dividers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Tiles button beside Split in the header, opt-in through the per-device
showTileGridButton setting (read in applyHeaderVisibilitySettings; the
settings checkbox, displayKeys membership and schema exclusion follow with
persistence) and hard-gated like Split: hidden by its --hidden marker, a JS
width check with a live media listener, a @media (max-width: 1179px) backstop
and never in a solo window. While the grid is open the button closes it and
reads as pressed.
Closed, it opens a picker: a checkbox per open session in tab order (never one
popped out to its own window; one with no PTY is offered, its tile shows the
Attach overlay), names as text, preselected with the grid this tab last left,
else the active session and an open split's two. Boxes past what the window
can fit are disabled with the count shown, and Open opens the grid on the
checked sessions, focusing the active one if checked. Escape and an outside
click close it; its close method is idempotent and the global Escape handler
calls it.
A tile's + lists the open sessions not yet tiled; picking one adds it and
focuses it (a human selection). A grid that already holds what the window fits
disables the entries. "New session in this case" waits for auto-join.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tile has no live terminal when its session has no PTY attached (pid null,
e.g. restored after a server restart), when the agent exited in a live pane
(paneExit), or when the server closed the tile's socket because the session
exited (4009, which used to leave only the "session ended" marker). Its body
now says which, with an Attach button, in an overlay laid over the terminal
so the body and its xterm keep their size.
Attach is the single view's own re-attach: POST /interactive (or /shell for a
shell) with NO body, at most one in flight per session, since the route has
no in-flight guard of its own. A tripped PTY-exit breaker goes through the
same confirm before clearBreaker: true, and nothing automatic ever sends it.
On success the tile is remounted onto the new pane (a socket stopped for good
cannot reconnect), keeping the keyboard if it had it; the overlay stays away
while the server catches up, and a failed attach says so and keeps it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
⤢ in a tile's header, or Alt+Shift+Enter (registry entry zoom-tile, applies
only while the grid is open and is swallowed before the Shift+Enter newline
gate), makes that tile fill the grid like tmux zoom. The other tiles stay
connected but hidden, so they measure nothing and send no resize; pressing it
again restores the grid and refits every tile, since the hidden ones have a
stale size. Zooming a tile that is not focused focuses it first (a human
selection).
As in tmux, moving focus to another tile restores the grid, and so does
removing the zoomed tile or adding one while a tile is zoomed by hand.
When the grid area cannot fit the tiles' minimum size, the grid zooms the
focused tile itself with a hint; that zoom follows focus and lifts once the
window fits again. A zoom the user chose is left alone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every tile gets a fixed-height header above its body: `● name ......... ⋯ ×`.
- The dot is the six-state classifier the tab rows and both home screens
share (_sidebarRichRow), with the existing .home-sessions-dot--* classes;
hovering the header says the state and for how long ("working 3m"). A
tile whose session waits on a permission prompt or question gets a pulsing
red border (box-shadow only, never layout; still under reduced motion).
- The name is text with data-i18n-skip; a double-click renames it through
the tab rename's own write queue (Enter or leaving the field commits,
Escape cancels, an IME composition owns Enter), and an in-flight name shows
as already applied, as on the tab.
- ⋯ is the tab rail's session menu (options, new window, close session with
its confirm); × removes the tile ONLY, the session keeps running. Neither
button focuses a tile that is not focused.
- The header is fixed at 24px so nothing in it can resize the body, and with
it the xterm and its PTY (#464).
Every tab render refreshes the headers, so they follow status and name
changes. Tabs of tiled sessions carry .in-tiles (both render paths), and the
tab strip re-renders when tiles come and go.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tile-grid-park-guards.test.ts carried its own copy of the fake DOM, written
before test/mocks/tile-grid-vm.ts existed, including the remove() that spliced
the wrong element when a child was no longer listed (fixed in the shared copy
only). It now uses the shared harness, which gains what the guards need: a
settable clock behind performance.now, the PerformanceObserver callbacks the
code under test registers, and localFit on the fake tile. Same 35 cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
xterm paints its viewport black, and a tile's rows rarely fill it exactly, so
every tile showed a black strip between its last row and its bottom edge. The
main terminal's container already makes the viewport transparent; tiles get
the same rule, so the gap shows the terminal background.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable):
- Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step
back after a selection outside it), else an open split as two tiles, else
the active session as one tile; pressed again, back to the single view of
the focused session. xterm emits nothing for a shifted Ctrl letter; the
browser's find-previous is overridden only where the grid can open.
- Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the
tile in that direction.
- Remove Focused Tile, unbound: the session keeps running.
tileShortcutFor() decides whether a chord applies (the toggle wherever a grid
could open, the rest only while one is open, so outside the grid
Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. The
capture handler dispatches it, and the main terminal's and every tile's xterm
key handler return false for it, for every event type and before the
Shift+Enter gate, so a chord that applies never reaches a PTY.
Coexistence with the split pane: opening the grid over an open split closes
it (no wasted resize for the pane about to park) and seeds the grid with both
of its sessions, Pane A focused. While the grid is open openSplitPicker and
openSplitPane refuse and the Split button reads as unavailable
(aria-disabled); closing the grid never reopens a split.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
selectSession gets the tile branch, right after its "already active" early
return: a tiled session is focused in its tile (_selectTiledSession: an
activeSessionId change, the shared _refreshSessionPanels and xterm.focus(),
no cleanup, replay, resize or socket of the parked main terminal). Decision 1:
a USER-initiated pick of a session that is not tiled leaves the grid for the
single view (the grid is remembered), and so does an explicit leaveTiles; an
app-driven pick (auto) never collapses it. A followed #session= link passes
leaveTiles (navigation).
App-driven paths pick a tile instead of the first sessionOrder entry:
- closeSession on the focused tile focuses the neighbouring tile (next in grid
order, else previous), captured before the await like wasActive, since the
delete broadcast may already have removed the tile; the last tile closes the
grid and falls back to the normal pick.
- A tiled session deleted elsewhere loses its tile and a neighbour takes focus
with auto (the last one lands on the welcome screen as before); a close from
this tab only drops the tile and leaves the follow-up to closeSession.
- A tiled session popped out to its own window leaves the grid.
Focus rules: pressing a tile is a human selection (pointerdown, never
preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a
human selection acknowledges an idle alert. Home leaves the grid (remembered);
killing every session closes it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tile-grid.js (load order 7.6) adds the grid to CodemanApp: openTileGrid,
closeTileGrid, addTile, removeTile and _selectTiledSession, over a
<section class="tile-grid"> that is a SIBLING of .terminal-wrap and takes its
place under .main.tiles-active. Every tile is a TerminalTile with the grid's
one load queue, TILE_SCROLLBACK, a bounded load and its own per-device font
size (codeman-tile-font-size; Ctrl +/- sizes the tiles while the grid is open).
Layout comes from computeTileLayout; one ResizeObserver on the section refits
each tile (xterm and PTY together) on the trailing edge.
Opening parks the main terminal: _cleanupPreviousSession runs once (its
snapshot is right at that moment, and it closes the main socket), and
activeSessionId always names the focused tile's session, so the panels follow
focus. With the main socket closed, every main-terminal path that would write
the focused tile's output into the hidden xterm, fetch a capture for it,
resize it or reopen its socket now stands aside through _tilesOwnTerminal():
the SSE terminal, clear and refresh handlers, the dropped-output recovery,
the completion/error writelns, retryConnection and handleInit (both re-arm the
tiles instead; handleInit keeps live tiles and drops dead ones), sendResize,
throttledResize, the history re-pull, and the WebGL long-task observer, which
watches the whole page and must not count tile renders toward the main
terminal's sticky WebGL disable. The header connection state comes from the
tile sockets.
Closing destroys every tile, invalidates the main terminal's cached content
(snapshot, codeman-xs key, buffer cache) for every tiled id, since it predates
the grid, and replays the focused session fresh in the single view.
_focusedPane() answers with the focused tile and _forEachTile reaches every
grid tile. A tile whose socket stops for good is removed (4003, 4004, 4010) or
keeps its "session ended" marker (4009).
No entry point yet: the grid is opened from the shortcut registry in a later
commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /api/sessions/:id/terminal runs synchronous tmux calls on the server, so
N tiles loading at once would stall every WebSocket and SSE stream back to
back (and after a deploy restart all N reopen within the same second).
TerminalTile takes the options PR 1 deferred to the grid:
- scheduleLoad(tile, kind, run): every capture the tile fetches (initial
load, reconnect refresh, server {t:'r'} refresh, shell history pull) runs
when its owner says so. Absent (the split's Pane B), a load runs at once.
- scrollback (the grid passes TILE_SCROLLBACK) and fontSize.
- boundedLoad: a TUI tile loads the bounded full=1&tail= window, never its
whole history.
TileLoadQueue (terminal-tile.js, DOM-free) is that one queue: concurrency 1,
a history pull ahead of background refreshes, then the owner's rank (the grid
ranks the focused tile first, then reading order). A destroyed tile's waiting
loads are dropped unrun, and destroy() aborts the running fetch so the queue
moves on.
Also, for Pane B as well: the load now has a deadline covering the body
(CodemanFetchDeadline), so a capture that never answers cannot hold the
single-flight flag (or the queue) forever; a refresh clears the screen at its
turn rather than when it is asked for; and a close while a load only waits in
the queue writes the disconnected marker at once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
window.CodemanTileGrid (constants.js), the grid's pure half:
- computeTileLayout: columns x rows by tile count (1x1, 2x1, 3x1 on a grid
area at least 1800px wide else 2x2, 2x2, 3x2, 3x3), capped at 9, and
whether every cell clears the minimum tile size (480x240).
- tileGridCapacity: how many tiles a grid area can hold.
- sanitizeTileGridState: a stored grid (ids only) made safe to apply;
unknown, deleted, detached and duplicate ids are dropped, focus and zoom
must name a kept tile, track fractions must be sane.
- tileNeighbor / tileInDirection / cycleTile: which tile takes focus when one
leaves, on a directional chord, and on Ctrl+Tab or Alt+[ ].
- TILE_SCROLLBACK (10,000 lines, not the primary pane's 50,000) and the tile
font default.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The block selectSession runs in an idle callback once the terminal content is
on screen (respawn banner and countdown, action log, task panel, Ralph state,
CLI info, project insights, subagent window visibility, file browser) moves
verbatim into its own method. The tile grid's focus change needs the same
refresh without the rest of selectSession, and one copy keeps the two from
drifting. No behavior change: the stale-generation guard moves with it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.
Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When SSE comes back after a server restart, handleInit's reconnect
branch already re-opens the primary pane's socket; it now also calls
the split pane tile's reconnectNow(), so Pane B no longer waits out its
backoff (up to 10 s between tries) after every deploy. Live: Pane B was
back 4.6 s after the server process respawned, i.e. as soon as it
listened.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the split open, every app-level terminal action resolved against
Pane A: Ctrl+L typed into Pane B cleared Pane A's display while xterm
sent the ^L into Pane B's PTY, and Ctrl+Shift+R restored Pane A's size.
_focusedPane() now answers with the terminal focused LAST (a mic or
header click moves DOM focus but not the user's pane): Pane B claims it
from its own textarea's focus, the primary terminal's focus gives it
back, and a destroyed pane never holds it. Ctrl+L, Ctrl+Shift+R, voice
dictation and image paste act on the focused pane. Ctrl+W deliberately
still closes the active session: it kills with no confirm, so moving it
is an owner decision (docs/tile-grid-plan.md, decision 5).
Also destroys every tile after each terminal-tile-input test: a real
reconnect timer from one test opened a socket in a later one and flaked
under full-suite load.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Pane B registers the primary pane's file-path/URL link provider on
its own terminal, so a path an agent prints there opens the file
preview or log viewer for Pane B's session (it was plain text).
- Ctrl+V/Cmd+V in Pane B goes through the primary pane's paste trap,
aimed at Pane B: a pasted image uploads to Pane B's session and its
path is typed there; text keeps its bracketed-paste markers. xterm's
default handled text only.
Tests pin both the tile wiring and the targeted link provider itself
(registered on the target terminal, opening against the target's
session at click time, the primary's tap-path provider untouched).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Font size, family and weight changes refit Pane B AND tell its PTY.
They used to reflow the xterm only, leaving the CLI wrapping at the old
column count, the garbled-redraw class #464 fixed for the primary pane.
- The resize frame reports the size the xterm actually holds, with no
40x10 floor (the divider's 20% clamp leaves about 28 columns), skips
an unchanged size, and is always re-sent on a fresh socket so it
re-registers as a desktop viewer.
- The server's {t:'zc'} geometry report is handled: a different column
count is adopted, rows stay local, using the primary pane's own
reconcilePtyGeometry verdict.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Codeman restart (every deploy) or a network blip used to leave Pane B
dead, with a marker asking the user to close and reopen the split.
TerminalTile now reconnects:
- A transient close reconnects on the primary pane's backoff ladder
(CodemanWsReconnect) plus jitter; the attempt count resets only on a
successful open. The redelivery sweep's forced close (1005) counts as
transient.
- On reopen the closed state is cleared before the buffer refresh that
closes the output gap, so no stale marker lands under a healthy pane.
- 4003/4004/4009/4010 stop the pane for good and report once through a
new onExit(code) callback; the marker says why.
- Sockets are replaced race-free: the old one is detached before a new
one opens, and every handler ignores events from a socket that is no
longer current. destroy() cancels a pending reconnect.
- reconnectNow() lets an owner skip the backoff.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TerminalTile used to send every xterm onData chunk as a bare {t:'i'}
frame: no seq, no ACK, silently dropped while its socket was down, and
typing never acknowledged the session's idle alert. Keystrokes and
pastes now go through app._sendInputAsync over the tile's own socket,
registered in the input-socket map while open (HTTP fallback while
not), so they are ACKed, persisted until delivered, redelivered after a
drop, and the ACK clears the idle alert.
What xterm generates on its own stays out of that persisted queue: a
query reply (DA/CPR/OSC) is dropped, as the primary pane drops it, and a
focus or mouse report goes out once via _sendInputEphemeral. The tile's
socket carries the tab identity with a :tile suffix so it can never
evict the primary pane's socket.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pure move and rename, no behavior change. The split pane's second
terminal (SplitTerminalPane) moves out of terminal-split.js into its own
terminal-tile.js (load order 7.4) as TerminalTile, so the tile grid can
reuse it. terminal-split.js keeps the split orchestration (picker,
divider, auto-collapse) and constructs a TerminalTile for Pane B.
Tests follow the class: split-pane-terminal-unit becomes
terminal-tile-unit, and the Shift+Enter guard and the two browser suites
read terminal-tile.js / window.TerminalTile. The browser suites match
master (one pre-existing environmental failure in both).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both read activeSessionId at the END of an async gap, so switching tabs
in between sent the input to the wrong session:
- An image upload inserted its paths with sendInput(), which re-reads
activeSessionId after the uploads finish. It now inserts into the
session the batch was uploaded to, through the same durable queue.
- Voice dictation read the target when the transcript arrived and again
when the send button or the compose overlay's Send was pressed. The
target is now captured in start() (via _focusedPane()), the local-echo
overlay is only used when that target is the active session, and a
target that closed meanwhile gets a toast instead of a 404.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:
- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
exactly-once input queue, its ACK handling and the redelivery sweep now
deliver over any registered socket bound to a session, not only
this._ws. ACKs are routed by the receiving socket's session; silence is
judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
copyTerminalSelection and _handleImagePaste take an optional target
terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
(primary only, for now); _forEachTile() replaces the _splitPane special
cases in the font, family, weight, skin and resize paths.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
cap (probeBeforeTouching), and whatever is still unknown is skipped. The
per-spawn hook and statusLine helpers used to fall back to an unbounded
lstat/readFile there, which on a dead workspace never settled and could take
the last threadpool workers (and hang the boot hook sweep). New test: cap
engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
GET /api/cases/:name now say a folder was not checked (other mounts are still
not answering) instead of blaming a healthy folder at the stall ceiling.
errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
.unreachable covers a refused probe, the boot sweep skips an unanswering
workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
(plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
block, and the cap-below-ceiling test no longer depends on an inherited
UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- createEditCoordinator's finally block rebases the edits queued during a write; one that the write's 409 made inapplicable was dropped with no toast. It is now reported once, like the main loop and adoptExternal do (found by the PR bot's re-review; regression test fails without it).
- At the 32-group server cap the row and group menus no longer offer a new group, which could only fail with an untranslated 'group limit reached'. MAX_GROUPS is exported from tab-layout-browser.js.
- CLAUDE.md names the pagehide keepalive as the one deliberate exception to 'never PUT the layout outside the coordinator'.
- The Dashboard wiki page describes tab groups in the vertical rail row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A cached list of repositories below a folder is re-checked against the
Docker case workspaces as they are now, so a repository linked as a Docker
workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
(unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
repository list (findWorkspaceRepo) and refreshes only that repository,
instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
any full status, so an unrelated repository above the workspace costs one
process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The doctor now judges candidates like the run mode's resolver: the PATH
hit, then each search dir, each one version-checked on its own and
skipped on a mismatch (a wrong `pi`/`grok` on the PATH no longer hides
the real one in a search dir). A search-dir candidate must be an
absolute path to an executable regular file, so a relative dir or a
file without the x bit reads as missing, as it does in the Run menu.
`isExecutableRegularFile` is exported from cli-executable-resolver.ts
and reused rather than copied.
- Every doctor probe passes killSignal: 'SIGKILL'; a --version that
ignores SIGTERM held the probe for its full runtime (15 s vs 5 s
measured with a TERM-trapping script).
- README no longer claims parity with the Run menu or nvm prefixes.
- The Diagnostics panel marks a missing optional tool with ○, a missing
required one with ✗, as the terminal doctor does.
- expandSearchDir names its twin, expandHome() in cli-resolver.ts.
- test/doctor-cli-json.test.ts is hermetic: temp HOME, a PATH of only
`which` and `node`, and a clis.json that drops the registry's absolute
search dirs, so it never runs the machine's installed agent CLIs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Route test hygiene: each test works in its own mkdtemp folder, every
deletion goes through safeRmHomeTree, and the suite refuses to start
outside test/setup.ts's temp HOME, so a raw `npx vitest` can no longer
delete a real ~/projects or the live linked-cases registry.
- Path policy: the symlink-resolved target is also judged against the
resolved home, data dir and system roots (home reached through a link,
macOS /etc -> /private/etc); test expectations are realpath-safe.
- Refuse a target equal to or inside the caller's or the shared cases
directory, pointing at plain Create New (it would list twice, and
deleting the local copy removes files).
- The registry re-read comment no longer claims to prevent the
lost-update race; documented as narrowing it, like /api/cases/link.
- UI: the success toast names the folder the server created, the
"under ~/codeman-cases" blurb and name hint change while a custom
folder is ticked, a "/" parent previews and sends /<name> instead of
an empty path, and the new labels have zh-CN entries.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The behavioural check for the detailed-rail rename clamp (#534, #526) lives in test/inline-rename.test.ts, a browser suite the CI gate does not run. This pins the cascade from styles.css itself, from computed selector specificity and source order, so a later clamp rule cannot silently out-rank the shared unclamp again. Mutation-checked: deleting the detailed-rail twin fails exactly that case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server
# Conflicts:
# src/web/routes/case-routes.ts
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
# Conflicts:
# config/test-suites.ts
# docs/api-reference.md
One consolidated minor changeset with the Thanks block first; the four contributor changesets (#520, #521, #522, #523) are folded into it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fixes for the three findings of the third review round of #499.
- minor: a composition on an empty prompt did not follow the prompt after
output or a resize. The post-write re-place in flushPendingWrites and the
resize observer both ran rerender() only when hasPending was true, and
hasPending deliberately excludes the composition, so the first word of a
prompt (an overlay holding only a composition) stayed on the old row over
whatever output moved there. Both sites now call rerender() unconditionally;
it already returns early when there is nothing to draw, so nothing changes
without a composition. New browser case drives the real
batchTerminalWrite/flushPendingWrites path against real xterm 6 and the
overlay built from source, moves the prompt from row 0 to row 3 and checks
the overlay follows (it fails on the old guard, overlay left on row 0), with
a parity case for pending text. The structure test pins the post-write site
through vm and the resize site, which is a closure inside initTerminal(), by
source.
- nit: removeChar() dropped the composition but did not repaint on its false
path, leaving a composition-only overlay on screen showing text the addon no
longer held. It now hides the overlay there when a composition was dropped.
Package tests cover that path and the flushed path repainting without the
tail.
- nit: the package README did not document setComposition() or the
composition getter and described hasPending as "any content". Added both to
the API tables plus a short IME composition section, reworded hasPending
(pending or flushed text, excludes the composition), and made the quick
start re-render unconditionally instead of teaching the hasPending guard.
The hasPending JSDoc says the same.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the three PRs that landed together on the
session create / launch / persistence path.
#514 findings (bot verdict merge-with-fixes):
- minor, fixed: SessionState.model was published and persisted for every
mode, so a codex/opencode cron session reported the app-wide Claude
default it never ran on. toState() now emits it only where the new
cliTakesSessionModel() holds (registry capability model.source ===
'claude-settings-file', no CLI id branch). POST /api/sessions uses the
same helper for its non-claude refusal, so refusal and publication cannot
drift. Recovery then hands back undefined for other modes on its own.
- nit, fixed: the `model` schema admitted a leading dash (and '.', '[').
The first character must now be a letter or digit; still a subset of the
registry's model-claude pattern, so nothing accepted is refused at launch.
- nit, fixed (reject, the consistent choice): `model` with
attachRemoteSession was silently dropped. Now a 400 INVALID_INPUT, as
#514 does for non-claude CLIs and quick-start does for remote cases.
advisorModel (#530) gets the same refusal there. effort and envOverrides
keep their older silent ignore on that branch so no existing caller breaks.
#515 finding (bot verdict merge, one nit):
- nit, fixed: the types/session.ts @fileoverview described CodexConfig as
(model, resumeSessionId); it now lists reasoningEffort, bypass,
animations and renderMode too.
Audit of the merged combination (not reviewed before):
- The conflict resolutions in session.ts (toState), types/session.ts,
reboot-restore-routes.ts, server.ts (restoreMuxSessions), CLAUDE.md and
skills/codeman/reference/endpoints.md (+ plugin mirror) keep both sides
correctly; nothing was lost or doubled.
- A claude session with both `model` and `advisorModel` launches with
`--model <id>` and ONE merged `--settings` JSON (ultracode + advisorModel,
or advisorModel beside `--effort <level>`), on the tmux template
(including the resume || new variant and with the statusLine exporter)
and on the direct-PTY fallback. Both values (and effort) survive
restoreMuxSessions onto a dead pane, a reboot restore into a fresh pane,
and restartCli/dead-pane respawn via _buildRespawnPaneOptions.
- quick-start and ralph-loop take no per-session `model` (matching #514's
scope, POST /api/sessions only) and launch on the app-wide default, which
toState now persists for claude, so recovery stays consistent.
- No defect found in the combination beyond the findings above. Noted, not
changed: advisorModel is still published for any mode a caller sends it
with (launch-inert there; the UI and skill send it for claude only).
Tests: test/session-model-recovery.test.ts pins the pair through both
recovery shapes for effort ultracode/high/none, the recovery constructors'
fields, the tmux-manager builder hop, and the codex/opencode/shell
non-publication; test/advisor-model.test.ts pins the launch lines and a
real direct-PTY Session's pty.spawn argv; the route test covers flag-shaped
models, attach refusals and the published fields. Docs: SessionState.model
docstring, the reboot-restore-registry header, the golden test comment and
the CLAUDE.md model/advisor bullets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.
#517 minors
- A collapsed group hid rows that need the user with no signal on its
header. The header now takes the most urgent alert among the session
rows its collapse hides, in the tab alert language (tab-alert-action
red ring, tab-alert-idle yellow ring, the existing ::before rules
extended to the header). New pure hiddenGroupAlerts() over a per-section
`hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
paths, since alerts change without a rebuild. The kept selection draws
its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
every 5 s forever. _applyTabLayout() now rebuilds only when the
structure key changed. The key drops the layout version (bumped on
every session create/close and order PUT) and instead carries group
names and the rows each collapse hides, so a version bump that moves
nothing costs nothing and a rename still rebuilds. The load coordinator
backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
good. A parse or shape error now reads as nothing collapsed and is
rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
re-ranks per group, sends no session:orderChanged and leaves this
client's sessionOrder and Alt+N targets diverged. The move is now a
no-op unless the neighbour is in the active session's own section
(_canSwapActiveTabWith, reading the projection's new sectionByRef, which
also covers rows a collapse hides). Within a group the swap still works
and the server agrees with it; the flat rail and the strip are
unchanged.
#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
(a test cannot read another revision's files portably). Checked by
hand instead: the flat strip and flat rail markup of this branch before
and after this commit are identical in all 16 cases (both orientations,
manual and activity sort, no layout and zero groups, full and
incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.
#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
the roving tabindex parks those at -1, so the [tabindex] arm of
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
a row (Enter on the overflow button re-selected and reloaded the active
session instead of reopening its menu). It now returns unless the key
landed on the treeitem itself.
#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
re-sorted rows on the incremental path. The position pass is extracted
(_applyTabTreePositions) and re-run, with aria-selected and the header
alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
owning an empty group. A group with no open rows is now a tree leaf: no
aria-expanded, no aria-owns, its rows container presentation; Left and
Right do nothing on it, and its chevron keys off the section's
collapsed class instead of aria-expanded.
Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).
M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.
M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.
M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.
Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
settings to turn MCP sync on first" instead of calling the routes; the 403 message also
says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
the route imported them, so no churn.
Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).
Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.
Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.
Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.
Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).
Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Minor: the Key tester "14 lines" test never pressed a key into the
tester (the previous test blurred it, so the presses landed on <body>
and the cap was never exercised). It now refocuses the field, asserts
the focus, clears the log, checks 2 presses accumulate to 6 lines, then
4 presses of another key cap the log at exactly 14 with the oldest 4
lines evicted in order, and the readonly field stays empty. Verified to
fail with the cap changed to 20.
- Nit: the split-pane invariant implied Ctrl+Enter could use the CLI's
declared newline chord. Reworded after checking the send-key route:
Ctrl+Enter is always a real 0x0a, Shift+Enter is the declared
capabilities.newline chord (0x0a unless the CLI declares another), sent
on keydown only. The same imprecision in the auto-named sessions
paragraph is corrected too.
- Nit: docs/wiki/Settings-Reference.md now lists the Key tester row in
the Terminal & Input table.
- Nit: test/shift-enter-keypress.browser.test.ts exercised a hand-copied
predicate named `shipped`. It now loads the real app from a real
WebServer and presses real keys into the handlers terminal-ui.js
(app.terminal, recording the real _sendInputAsync send path) and
terminal-split.js (a real SplitTerminalPane) attach, recording the
send-key POSTs through a fetch wrapper. It asserts no \r reaches either
send path for Shift/Ctrl+Enter, exactly one send-key per press for the
right session, and that Enter and Alt+Enter are untouched. The old
keydown-only gate stays as a labelled reproduction of xterm's keypress
behaviour on a bare Terminal. Verified to fail on both panes with the
gate narrowed back to keydown.
- Nit: the keypress trap is now written down beside the other key-gate
rules (Command palette and shortcut registry): xterm runs the custom
handler for keydown, keypress and keyup and drops only Ctrl/Alt/Meta
keypresses, so a gate on a chord that can carry Shift alone must
swallow every event type. The smart-copy keydown-only rule points at it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Stale second marker above a trailing refresh's replay: xterm parses
write() on a later tick while clear() is synchronous, so a marker stamped
in a load's finally, just before _endBufferLoad() starts the trailing
refresh, landed in the freshly cleared buffer above that refresh's replay.
_stampMarkerIfOwed() now returns early while a refresh is pending; that
refresh re-owes the marker on a closed socket and writes the one copy
below its own replay. Pinned by marker-count assertions on the two
existing trailing-refresh tests plus a new async-parse fake (writes
parsed on a later tick, clear() synchronous) for back-to-back refreshes
and a pull with a queued refresh and a close mid-pull; all four fail
without the guard. Also checked against a real @xterm/headless 6.0.0.
- Marker withheld for up to the 45 s request budget: kept the behaviour and
made the comment and the docs truthful. The pull's request phase holds no
live output, but it holds the single-flight flag, so a coalesced {t:'r'}
refresh and a close's owed marker wait for the response. Writing the
marker at once during that phase would need a separate "awaiting
response" state and, with a refresh pending, reopens the same
write-vs-clear() race as above; a Codeman restart resets the in-flight
request along with the socket, so that pull fails at once and stamps.
- Stale comments: _onSocketClosed() now says the deferral covers any load,
_writeDisconnectedMarker() points at _stampMarkerIfOwed(), and the pull's
finally comment describes the hand-off to a trailing refresh.
- Invariants doc: dropped "the initial load" from the loads a close can land
in (connect() awaits it before creating the socket), reworded the
"nested refresh stamps its own" sentence to describe the guard, and noted
what the request phase holds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ensureStatusLineExporterScript() rewrites ~/.codeman/statusline-exporter.sh
via a temp file + rename whenever the script content changes (a fresh data
dir, or a release that changes it). The temp name was pid + Date.now(), so
claude sessions created in the same millisecond (spawn_workers, a multi-tab
Run) shared one temp path: the first rename consumed it and every other
writer failed with ENOENT on chmod or rename. createSession() treats that as
a mux failure and falls back to a direct PTY, so those sessions silently ran
outside tmux (no reattach after a server restart) while quick-start still
reported success.
Measured on a fresh isolated instance, 4 concurrent claude quick-starts:
master put 2 of 4 in tmux in both rounds; with this change 4 of 4, both
rounds. The temp suffix now comes from randomBytes, like the skill writer in
the same file and user-store.ts already do. The new test freezes Date.now()
and runs eight refreshes at once; it fails on master with the same ENOENT.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
spawn_worker builds its quick-start body itself ({caseName, mode,
parentSessionId}), so an agent driving the skill had no way to give a worker
the advisor without hand-building the call and losing the readiness ladder,
hooks vetting and trust-dialog fallback. Setting CODEMAN_WORKER_ADVISOR
(fable / opus / sonnet) now adds `advisorModel` for every claude worker that
spawn_worker or spawn_workers starts; other modes ignore it.
A refused value fails the spawn with the server's INVALID_INPUT message. A
server without advisor support drops the field silently (the schema is not
strict), so spawn_worker reads it back and says so on stderr.
The preamble changed, so CODEMAN_PREAMBLE is bumped to 1.33.4 and stale
cached copies are rewritten instead of silently ignoring the variable.
SKILL.md's heredoc and the plugin mirror are synced.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's
main model consult a second, stronger model at decision points: before
committing to an approach, on a recurring error, and before declaring a task
done. Codeman can now start claude sessions with one.
- `advisorModel` field on POST /api/sessions, /api/quick-start and
/api/ralph-loop/start (fable, opus, sonnet or a full model id in those
families; haiku cannot advise and is refused). Stored on the session and
persisted, so respawn, boot restore and reboot restore keep it. Remote and
docker quick-starts refuse it, as they refuse effort.
- App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable),
synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it.
Default sends nothing, leaving the CLI's own /advisor choice in charge.
- Carried as the `advisorModel` key in the launch's single --settings JSON,
merged with ultracode and the statusLine exporter, never the --advisor
flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead
pane on every respawn, while the settings key degrades to no advisor. A
launch without an advisor is byte-identical to before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A #session=<id> link whose session never appears (closed, a typo, or
another user's session in multi-user mode) is dropped after
URL_SESSION_WAIT_MS (30 s) with a "Session not found" toast instead of
waiting forever. One stored timer per link, cleared whenever the link is
followed, replaced by a newer link, or retired.
- goHome() and opening a web tab now retire a waiting link, so a session
that turns up later no longer takes the screen. App-made web tab opens
(frame self-recovery, the fallback after the active web tab closes) pass
auto: true and keep it, as selectSession() does.
- zh-CN translation for the new toast.
- selectSession's auto: true comment now lists the #session=<id> link.
- docs: the 30 s bound, a win.location.replace() tip that avoids piling up
history entries, and the fragment declared a stable SemVer surface in
versioning-policy.md.
- Tests: timeout drops and toasts, an early arrival is still selected, the
wait does not restart, goHome and a web tab retire it, an auto web tab
open keeps it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A markdown preview opened by attachment id under a bare file name
(attachment cards, history drawer) no longer resolves relative refs
against the workspace root: filePreviewText carries attachmentId, and
the rebase pass turns those images into their alt text and unwraps
those links. Absolute-path and workspace previews are unchanged.
- _renderMarkdown(text, { breaks = true } = {}): the File Viewer passes
breaks: false, so a hard-wrapped paragraph renders as one paragraph;
the Response Viewer keeps a <br> per newline.
- Absolute paths linkified inside a rendered document now carry the
preview's data-session-id.
- CLAUDE.md, architecture-invariants and the Working-With-Files wiki page
now say that only an in-workspace path clicked in the terminal keeps
the tail viewer.
- Tests in test/file-preview-markdown.test.ts for all three fixes,
including an end-to-end run of the shipping app.js + marked + DOMPurify.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- terminal-split.js: move the socket's close into _onSocketClosed(), which
defers the marker while a history pull holds live output (_liveQueue);
_pullHistory() records closedBefore and its finally writes the marker
after the queue flush when the socket closed during the pull, replayed
or not, so it never lands above held frames or between replay chunks
- tests: drive the real close path for a close mid-fetch ending in a skip,
a downgrade or a failed fetch, a close during the chunked replay, and a
close with no pull running; pin the onclose wiring in the static guard;
describe the mid-fetch case on its own
- CLAUDE.md: turn the plain-text split-pane pointer into a link
- architecture-invariants.md: describe the deferred marker
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- _restoreOverlayFocus(key, modal) now leaves focus alone when something
outside the overlay already holds it (not <body>, not inside the modal).
The Session Manager's "Switch to session" and "Open folder" call
selectSession() before closeSessionManager(), and the restore was pulling
focus back from the terminal to the header button. Both close methods pass
their modal; a regression test drives that order.
- Test harness: focusHarness() routes getElementById through a local binding
instead of leaking globalThis.__els, and its modal stubs report their own
search box as contained, as the real DOM does.
- CLAUDE.md and docs/architecture-invariants.md: record that the global
Escape handler calls every close method on every Escape (capture phase),
so a close method with side effects must return early when not open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A cron job in "Paste (direct)" input mode wrote `<text>\r` into the pane
in one piece. Claude Code (measured on 2.1.283) takes a burst of about a
hundred characters as a paste, so the `\r` landed as a newline and the
prompt sat unsent on the composer while the run reported `prompt_sent`.
Delivery now lives in `deliverCronPrompt()`. Paste mode writes the text
raw, waits CRON_PASTE_ENTER_DELAY_MS (300 ms), sends `\r` as a separate
write down the same PTY (so it cannot overtake the text), and arms the
session's composer check through the new public
`Session.verifySubmitted()`, which re-presses Enter while the prompt is
still visibly unsent. A session with nothing to write to now fails the
run instead of reporting the prompt as sent. Typed mode is unchanged.
Verified on an isolated instance: a paste-mode job with a 104-character
prompt submitted on the first Enter and Claude answered.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A prompt posted to /api/sessions/:id/input without `useMux` was written
into the pane in one piece. Claude Code (measured on 2.1.283) takes a
`<text>\r` burst of about a hundred characters or more as a paste, so the
trailing `\r` landed as a newline in the composer and the prompt sat there
unsent while the route answered 200. A later raw `\r` did not recover it;
a tmux `send-keys Enter` did. Short prompts submitted, which is why it
looked random. The same stranding was seen with Codex and OpenCode.
A plain prompt (printable text plus exactly one trailing `\r`, detected by
`isPlainPromptInput()`) now goes through `writeViaMux` even without
`useMux`: the text is typed, Enter is pressed as its own key, and the
SubmitVerifier re-presses it while the prompt is still on the composer.
The write is awaited, since the browser's POST fallback sends frames one
at a time and a following keystroke must not overtake the Enter. Raw
frames (escape sequences, bracketed paste, a line feed, a bare `\r`) and
an explicit `useMux: false` keep the direct write.
Verified on an isolated instance: the 239- and 104-character prompts that
stranded (at +1 s, at +50 s on ultracode, and on a warm session) all
submitted on the first Enter with no `useMux`. The phone's local-echo
flow (a burst, then its `\r` as a separate write) was measured unaffected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folds the #490 and #492 contributor changesets (the latter said minor) into one patch changeset with the Thanks section, one paragraph per change and the fixes applied while landing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- _logScrollRouting() reports cliMouseTracking, the gate's new input, in both
the de-dup signature and the console line (xterm's own mouseTracking stays
'none' for Claude, so it gave no reason for a no).
- Restore two guard tests the new gate made vacuous: the local-scrollback
opt-out footgun test and the codex/gemini "no version rescues it" fixtures
now set cliMouseTracking: true, so removing the opt-out or re-adding codex to
the gate fails again.
- Update the comments and architecture-invariants lines that still described
the version-only rule (wheel handler header, gate doc, the false paths of
_maybePageCliTranscript, "holds a tracking mode on continuously").
- Name both fullscreen switches (CLAUDE_CODE_NO_FLICKER=1 and "tui":
"fullscreen" in ~/.claude/settings.json) in the code comment, the invariants
and the two wiki pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Skip and latch a bounded Shell window once the browser is at xterm's
scrollback cap (scrollback + rows): a 1 MiB window of short lines can carry
more rows than the browser can ever hold, so it replayed and re-captured on
every scroll-to-top with no 60 s back-off.
- Label a replayed bounded window 'tail' even when the capture was byte-capped,
so the banner keeps offering Load full history instead of calling the rest
unrecoverable.
- Pin GET /terminal?full=1&tail=<n> in the route tests: full-history source,
truncationReason 'tail', and the closing relative cursor move survive the cut.
- Log the bounded skip via _logScrollRouting('repull-skipped-bounded').
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- pre-push hook: skip with a notice when npm is not on PATH (GUI git
clients and IDEs often run hooks with a minimal PATH), instead of
blocking every push on "npm: not found"; real-push test with a
stripped PATH
- test/git-hooks.test.ts: pin GIT_CONFIG_NOSYSTEM=1 and
GIT_CONFIG_GLOBAL=/dev/null around the resolveGitHooksDir tests, so
an exported global or a system core.hooksPath no longer fails them
- watch tsconfig.json, .prettierignore and .editorconfig too:
typecheck and format:check read them
- check:browser-excludes: fail loudly when the vitest list output and
the walked test/**/*.test.ts tree share no path (format drift would
otherwise pass vacuously)
- Reword the PRE_PUSH_MARKER comment: bumping its version would make every
installed v1 hook read as foreign and never refresh again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- claude's declared-for-later wheelForward says the live rule in
_shouldForwardWheelToApp is the version AND the server-published
cliMouseTracking flag, so whoever wires the field up needs both
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- claude watchingLine: the lookahead keys on "Artifact" alone, so a
footer truncated mid-chip ("1 Artifact…", "1 Artifact comm…") is still
refused instead of reporting the shell beside it; comment follows
- test: both truncations return no watching label
- invariants: a chip that waits on a human never counts as watching, and
the ^ anchor is what stops the retry past the chip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- test: the complete-identity case now checks the combined
agentImageBuildArgPairs() argv on both producers, so the manual
build-agent-image.mjs path cannot drop the identity unnoticed
- both producers: GIT_IDENTITY_BUILD_ARGS carries the mirror/parity
warning its gh/az neighbour has
- the partial-identity error names CODEMAN_AGENT_IMAGE_GIT_USER_NAME and
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL; test regex follows
- wiki Docker-Cases: mention the identity variables next to the gh/az
switches
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- test: every ENV PATH= line in server.Dockerfile must start $PATH:, and
the ~/.local/bin append is pinned alongside /opt/codeman-cli/bin
- invariants + CLAUDE.md: the append-only PATH rule names ~/.local/bin too
- docker-compose.md: Settings-installed CLIs live in ~/.local on the
app-data mount; reinstall once after upgrading; hand-run npm installs
need --prefix ~/.local
- installEnv() JSDoc describes the in-container npm prefix redirect
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>