mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
6fa807c2c31bbae400de6342759f256538b6bb71
608
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
ea80c5f471 | docs(tabs): correct the tab-layout constructor comment | ||
|
|
bd4a1e9886 |
fix(tabs): grouped rail editing review fixes
- Pointer drag: a press released outside the rail no longer lingers. The
release is heard on window while a press is pending, a move with the
primary button up cancels it, a new press cancels any previous drag, and
an existing Escape listener is removed before another is added, so no
orphaned capture listener can swallow Escape before the terminal.
- Inline group rename: a commit by blur leaves focus where the user put it;
Enter and Escape still return focus to the header.
- A failed layout read while edits are pending keeps the held layout and the
editor and re-reads once the write settles, so a 409 is still rebased.
Dropping unsaved work now always says so in a toast.
- "Move to <group>" quotes the group name (with a matching zh-CN pattern), so
a group named "New group" or "ungrouped" no longer reads or translates like
the fixed entries.
- The group menu glyph stays visible under (hover: none).
- The sessionStorage replay copy carries { owner, baseVersion, savedAt } and is
ignored for another owner, after 60 s, or against an older layout. A move
with no anchor carries no index, so a replay keeps the row last.
- A 400 that survives the re-read is reported as "Could not save tab groups."
- closeTabRailActionMenu() no longer removes the group menu's DOM.
- Cancelling "Delete group" returns focus to the header.
- Stale comments updated.
|
||
|
|
97cb5b5799 |
feat(tabs): edit groups in the vertical rail
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.
Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
reorderGroup, moveRef) applied to the rail at once, mirroring the server
model: a moved session takes the sessions that still follow it, and a
hand-moved child is marked placement 'manual'. normalizeLayout now keeps
placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
made in the same turn share a write; edits made while one is in flight go
out on the version it returns. A 409 replays the operations onto the
layout the server returned and retries (bounded); an operation that no
longer applies is dropped and reported. A 400 re-reads first; any other
failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
that changes nothing.
Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
Ungrouped and Move to new group in the vertical rail. Before the first
group exists it offers only "Move to new group", which is how a flat rail
becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
a hover glyph (a non-focusable aria-hidden span, so the treeitem still
holds no interactive child): Rename, New group, Move group up/down,
Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
back to its row or header), a pointer outside, Tab, focus leaving it, a
resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
the current editor releases the render guard. Enter or blur commits,
Escape cancels, IME composition keys are left to the IME, and the label
becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
row or into a group, a header drag reorders groups. Escape cancels; the
click that ends a drag neither selects nor toggles. The flat rail and the
header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
once it settles; a read otherwise rebases unsaved edits. On pagehide,
unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.
Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.
Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
|
||
|
|
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> |
||
|
|
fa53a5751e |
Merge pull request #519 from aakhter/pr/grouped-rail-tree
feat(tabs): full-row activation and tree semantics for the grouped rail |
||
|
|
23d145b121 |
Merge pull request #517 from aakhter/pr/grouped-vertical-rail
feat(tabs): grouped vertical rail from owner tab layouts |
||
|
|
2cf37529e9 |
fix(terminal): address review: Key tester isolates shortcuts, Codex stays on line feed
- app.js: the shortcut dispatcher returns early for events aimed at a data-raw-keys field, so Ctrl+W / Ctrl+L / Escape / Alt+1 / Ctrl+K pressed in the Key tester no longer kill the session, clear the terminal or close Settings - stock.ts: drop Codex's esc-enter (a line feed works); no stock CLI declares a chord. The esc-enter path is tested through a clis.json override - tests: unused port (3194), Ctrl+Enter asserts no keypress, shortcut-isolation test (verified to fail without the guard) - docs/comments point at capabilities.newline; set-input class, trailing whitespace Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
98c6c1881d |
feat(tabs): full-row activation and tree semantics for the grouped rail
The grouped vertical rail is now an ARIA tree with a tree keyboard model, and tab rows are pinned as full-row activation targets whose controls keep their own actions and stable hit targets. Tree semantics (grouped vertical rail only): - #sessionTabs becomes role=tree while grouped and returns to its shipped role=tablist and label when grouping ends. The header strip, sidebar and flat rail keep role=tablist / role=tab exactly as before (the flat rail's markup is unchanged byte for byte). - A named group's header is a level-1 treeitem with aria-expanded that aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the row a collapsed group keeps showing are level-1 items; a collapsed header owns nothing, and the "Ungrouped" heading is a visual divider hidden from assistive tech. aria-level, aria-setsize and aria-posinset are set on every item, and aria-selected follows the selection without a rebuild. - Exactly one treeitem carries tabindex=0 (roving). Controls inside rows leave the tab order, so Shift+F10 / ContextMenu open a row's actions (session action menu, web tab settings). - Up/Down walk visible items, Home/End jump, Right expands a header or enters it, Left collapses a header or climbs from a row to its header, Enter/Space select a row or toggle a header. With the activity sort on, the walk follows painted order within each group; the flat list keeps its whole-list walk. - Focus survives a full re-render by identity (a row a collapse just hid hands focus to its header), but a render never pulls focus into the rail. - The group header is the treeitem itself (no nested button), still toggled by click through the same onclick and still the lineage proxy anchor. Full-row activation: - Clicking a row's status dot, mode chip, name or padding already selected it upstream; that is now pinned in real Chromium for the strip, the flat rail and the grouped rail, together with every control (gear, detach, close, overflow, web tab gear and close) running only its own action. - The close control now shows a pointer like its siblings instead of the default arrow. - Enter/Space on a focused web tab in the flat list opens it; it used to call selectSession(undefined). - The action controls are pinned to stay under the pointer when a row is hovered (no reflow-on-hover moving the gear out from under a click). New Chromium suite test/tab-activation.browser.test.ts is listed in BROWSER_TEST_GLOBS (run with npm run test:browser). |
||
|
|
7cbce5bf6c |
feat(tabs): grouped vertical rail from owner tab layouts
The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout) and draws its groups as collapsible sections. This is the first frontend consumer of the tab-layout backend and it is read-only: nothing in the browser writes the layout yet. - tab-layout-browser.js (new, pure, loaded before app.js): projects the layout onto the live sessions and open web tabs, renders the grouped markup, stores collapse per device, and sequences loads newest-wins with a bounded retry on failure. - app.js: loads the layout on init and on tab:layoutChanged, renders the grouped rail from the same per-row markup the flat rail uses, falls through to a full render whenever the grouping structure changes, and withholds drag-reorder in the grouped rail. - Grouping is opt-in by construction. With no layout, a failed read, a layout without groups, or a horizontal strip, the rail renders exactly as before (byte-identical markup). - Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the palette keep reading the server-projected order, and row badges keep their Alt+N slot. - A collapsed group still shows the active row; lineage arcs to a hidden session anchor to its group header. - webview-tabs.js: renderWebviewTab() extracted so a single web tab can be placed into its group with unchanged markup. |
||
|
|
846c62fbf7 |
fix(web): bound a pending #session= link and retire it on Home or a web tab (#507 review)
- 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> |
||
|
|
73c0bfccc4 |
fix(files): keep attachment markdown refs from resolving into the workspace, and render files without chat line breaks (#503 review)
- 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>
|
||
|
|
5f5de5827e |
Merge pull request #503 from JDProfresh/feat/file-viewer-markdown
feat(files): render markdown in the File Viewer, with Lines/Wrap toggles |
||
|
|
01eb8ef08a |
feat(web): select a dashboard session from a #session=<id> link
A page that keeps one Codeman window open, such as a task board, could only show a session by sending that window to /session/<id>, which loads the whole app again for every click. The dashboard now reads a #session=<id> fragment when it loads and on hashchange, selects that session, and removes the fragment with history.replaceState so the next identical link is still a change. Re-pointing a window that already shows the dashboard changes only the fragment, so the page stays loaded and the switch is a tab change. A link can name a session the dashboard does not list yet, because the page that created it may link before session:created arrives. The id waits until that event names it, and picking another tab yourself retires it. Following a link is an app selection (`auto: true`). The page that set the fragment may be a script, so it must not spend the session's idle alert. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
612c69d57a |
fix(files): decode markdown refs, scope links to the preview session, drop name= from the sanitizer
Review follow-up on #503. marked percent-encodes link and image destinations, and the rebase pass encoded them a second time, so a space or a CJK character in a file name made file-raw look for a file literally named my%20image.png; refs are now decoded once (a malformed escape is kept as written) and stripped of ?query along with #fragment. Root-relative refs resolve from the workspace root as on GitHub instead of falling through as Codeman URLs. Rebased links carry the preview's own session id and the response-viewer delegate prefers it, so a document opened from another session's attachment card opens its links in that workspace rather than the active tab's. The sanitizer no longer allows name=: marked never emits it, and <img name="app"> made document.app that image, which every inline onclick="app.…()" handler resolves before the global, so one rendered README broke every viewer button until a reload. Adds the zh-CN strings for the three toolbar titles. |
||
|
|
714050fe8a |
fix(terminal): merge-time fixes for #494
- 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>
|
||
|
|
f6aa50239f |
fix(terminal): skip a bounded Shell window before the downgrade guard
A window cut at the tail size can be smaller than the browser's buffer while tmux still holds more. The downgrade guard reads that as "tmux has nothing more to give", which is true of an unbounded capture only, so a bounded window reaching it marked the session exhausted and removed Load full history from the banner. The bounded skip now runs first, so such a window never reaches the exhausted path, and it no longer writes banner state: relabelling it from the bounded payload would call a terminal holding all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated cannot reach anything older than the browser shows, and every ask costs the server a synchronous capture-pane of the whole history (tail is applied after the capture), so it puts the session on the 60 s cooldown. An untruncated one keeps 4 s. _replayWouldShrinkBuffer takes optional pre-estimated rows so a megabyte capture is not scanned twice. CLAUDE.md's Full-scrollback replay entry no longer says Shell never pulls on ordinary scroll. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
9676e90133 |
fix(terminal): let a Shell pane's scroll-up reach tmux history
A burst of output leaves a Shell pane with about one screen of browser scrollback, because tmux repaints the burst instead of scrolling it, while tmux itself keeps every line. Shell declined the scroll-to-top re-pull other modes use, and the Load full history button renders only once a replay was truncated, so a Shell tab under 1 MiB could not scroll back at all. The scroll gesture now pulls ?full=1&tail=TERMINAL_TAIL_SIZE, the same bound a tab switch loads; the route's existing tail cut marks longer histories 'tail', so the banner still offers the unbounded pull. A window no longer than the browser's buffer is not rewritten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
b80d47aff8 |
feat(session): close sessions whose agent exited cleanly (#486)
* fix(cleanup): keep .claude-images while a sibling session uses the same dir
cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.
The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.
Split out ahead of the exited-agent sweep for Ark0N/Codeman#446, which closes
sessions unattended and would otherwise make the loss routine.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(session): close sessions whose agent exited cleanly (#446)
Part 2 of Ark0N/Codeman#446. Part 1 records an exited agent as
SessionState.paneExit. A session whose agent the user ended with /exit is
now closed through cleanupSession(), the same path the X button takes, so
finished sessions stop piling up on the board. The lifecycle log records
the reason as "agent exited cleanly (status 0)", and the conversation stays
resumable from the Resume list.
shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts
holds the rule. It closes a session only when all of these hold:
- The exit status is an explicit numeric 0 with no signal. An absent status
is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the
row stays. A non-zero status or any signal also keeps the row, with the
exit code on the tab.
- Two authoritative pane reads agreed on that exit.
TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or
skipped read neither confirms nor resets the count.
- No start, attach or relaunch is running for the pane.
Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose
dead-pane branch revives an exited pane on purpose, and restartCli().
setPaneExit() already scopes paneExit to local mux-backed sessions, so
remote, docker and direct-PTY sessions are never closed.
planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit. That covers an agent that exited just before a reboot, before
the sweep reached it. A crashed agent's record stays eligible, like its row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(web): show "exited" on the phone overview and desktop home rail (#446)
Part 1 of Ark0N/Codeman#446 taught the tab strip and the rich rail rows
to say that a session's agent has exited. The phone overview and the
desktop home rail still said "idle", beside a green or pulsing dot.
_mobileOverviewExit() in mobile-overview.js is now the one rule for all
three surfaces, and _sidebarRichRow() uses it as well. It changes what a
row shows and leaves the row's state alone, because the state still picks
the section and the sort order. An exited row gets an "exited" pill, a
neutral dot and row accent, and a duration measured from when the server
first saw the pane dead. A pending permission prompt or question still
wins, as it does on the tab.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cleanup): close the gaps review found in the #446 sweep and image guard
Four fixes from a dual review of Ark0N/Codeman#446 part 2.
- The .claude-images guard compares canonical paths, so a sibling that
reaches the same directory through a symlink keeps it. Its comment used to
say that case only missed a deletion; it caused one.
- A detached session counts as a live sibling. DELETE ?killMux=false removes
it from the server's map while its pane keeps running, so the guard now
reads persisted records too, and exempts only sessions being killed rather
than every session in cleaningUp.
- A session being closed refuses startInteractive() and startShell(). The
/interactive route awaits listener setup before the start, and a start
that raced the close could launch a CLI in a tmux session whose record was
then deleted. A failed close clears the mark again.
- The clean-exit sweep tries each exit once, keyed by session id and the
exit's at stamp, so a close that fails is not retried and logged every
two seconds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(session): keep a clean exit that lands within 10 s of a pane start (#446)
A CLI that prints a startup error ("not logged in", a bad profile, a config
error) and exits 0 used to lose its tab, and the error with it, about 4 s
after launch. The sweep now keeps any clean exit that lands within
CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch
finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The
row stays as "exited (0)" for the user to read and close.
Verified on an isolated instance: a shell that ran `exit 0` 2 s after start
kept its row, one that exited after 13 s was closed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
|
|
d67da5c9d0 |
fix(input): an oversized paste no longer poisons the durable input queue (#484)
A single input over MAX_INPUT_LENGTH (64 KiB) was queued for reliable
delivery, refused by both transports (the WebSocket silently, POST with a
400), and never dropped: the client treated the 400 as transient, so the
frame was re-sent every 2 s forever, blocked every later input for that
session, and came back from localStorage on every reload.
- Client: a paste over the frame limit is split into in-limit frames
(never cutting a surrogate pair) delivered in seq order; over 1 MiB, or
an oversized mux write, it is refused with a toast and never queued.
- Client: the POST drain drops a frame answered 400/413; a WS error ACK
drops it too; frames over the limit persisted by an older build are
pruned on load.
- Server: the WebSocket answers an oversized sequenced frame with
{t:'ia',seq,err:'too_large',max} instead of silence (an older client
reads that as a plain ACK and drops it); the POST schema uses
MAX_INPUT_LENGTH instead of a second 100000 limit.
Verified end to end on an isolated instance: a 110 KB paste reached the
PTY byte-identical over both the WebSocket and the POST path, a poisoned
120 KB persisted frame was pruned on load, and a 2 MB paste showed the
refusal toast with nothing queued.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
|
|
cbd1fa639d |
fix(tmux): merge-time fixes for the exited-agent report (#466)
- docs/wiki/The-Dashboard.md: the tab-appearance table gains the exited state (muted dot plus an `exited (137)` badge) and explains the bare `exited` variant. - The detailed sidebar and rail no longer pair the muted dot with an "idle" pill: an exited session's pill reads "exited" (neutral styling) and its since stamp measures from the observed exit. This is a label override on the row model, not a new state, so SESSION_ACTIVITY_RANK and the home screen order are untouched, and a pending alert still keeps its own pill. The row signature includes the flag so the incremental path repaints it. - The exited badge is aria-hidden like its sibling badges, and the exit is appended to the tab's aria-label in both render paths through one helper. - test/tmux-manager.test.ts re-adds the junk-trailing-field parser case against parsePaneRows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
f4d1ee8027 |
fix(terminal): merge-time fixes for dropped-output recovery (#470)
- The TERMINAL DROP crash-trail line moves behind the scheduler's debounce guard, so it is written once per window rather than once per dropped frame. At the server's 8ms batching, one second of drops evicted the whole 50-entry trail, including the recovery lines that explain it. - A refresh that failed at the capture fetch deadline now returns 'deadline', and the scheduler does not retry it: that is a stalled link, not contention, and each retry was another ?full=1 capture waiting out a deadline of up to two minutes. The early-return retries are unchanged. CLAUDE.md and the code comments no longer claim every skip reason is transient contention. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
7a30a31430 |
fix(terminal): merge-time fixes for the silent-failure paths (#431)
- While another device holds the pane width (_paneWidthRefused), a resize now asks for the container's width without applying it locally (_geometryForResizeRequest: rows follow the container, columns stay at the PTY's). Fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and throttledResize ran the scrollback clear for a resize that brings no redraw. selectSession clears the flag, since it belongs to the previous pane. New unit tests run the real mixin against a fake terminal and fail without the fix. - Session seeds _ptyCols/_ptyRows at spawn (_notePtySpawnGeometry), so a reattached pane reports its tmux window's real size through ptyGeometry. - Session.resize's declined-branch comment names ptyGeometry, not the deleted ptyCols/ptyRows getters. - Delete the dead terminalGeometryAgrees() and its window export. - test/xterm-private-api.test.ts header: it pins the exact locked version, so any bump fails, not only a major. - The main-terminal fit sweep also matches fitAddon?.fit?.(), and CLAUDE.md names the modules it actually covers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
2fb744f865 |
Merge pull request #470 from rounakdatta/fix/dropped-output-recovery
fix(terminal): recover a dropped output frame, do not merely schedule it |
||
|
|
de4b1db490 |
Merge pull request #431 from rounakdatta/feat/mobile-terminal-resilience
fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches |
||
|
|
8536aaef7b |
Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge
feat(approvals): let a session watching its own background work keep quiet (#468) # Conflicts: # src/config/cli-registry/stock.ts |
||
|
|
00f022ccf8 |
fix(terminal): recover a dropped output frame, do not merely schedule it
`_onSessionTerminal` drops an incoming frame once the app-owned render queues already hold 128KB. That is the right call — the alternative is an unbounded backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced cursor is muffled text (#464). The drop was only half of it. The recovery was a fire-and-forget timer: it nulled its own handle and then called `_onSessionNeedsRefresh()`, which opens with four early returns. Two of them — a buffer load already in flight, a refresh already owning this session — are MOST likely to be true during exactly the output burst that caused the drop. So the recovery was skipped precisely when it was needed, with nothing left to retry it, and the dropped bytes were never replayed. `_onSessionNeedsRefresh` reports whether it actually repainted now, and `_scheduleDroppedOutputRecovery` re-arms while it has not. Bounded by `DROP_RECOVERY_MAX_ATTEMPTS`, because every reason the refresh can be skipped is transient contention that clears in seconds and a permanently failing refresh must not become a loop against the API; giving up at the cap leaves exactly what the old code left, so the floor is no worse than before. The same 2s debounce still collapses a burst of drops into one attempt. This is the principle Ark0N established reviewing #431 for the WebSocket output-gap marker — only a repaint that actually happened settles the recovery — applied to the one recovery path that still trusted a timer having fired. The retry decision is a pure function in constants.js so the gate can reach it, and the scheduler itself is driven from app.js under a fake clock. The retry case and the no-retry case only pin the fix AS A PAIR: either alone passes against something wrong, one against the old fire-and-forget timer and the other against retrying forever. Checked by reverting app.js to the old shape, where three of the twelve fail. Two harness details that would otherwise have made the tests lie. The vm context baked in the real `setTimeout`, so `vi.useFakeTimers()` could not reach the scheduler and every case reported zero calls; it delegates lazily now. And app.js reached `CodemanDroppedOutput` as a bare global, which resolves in a browser but not in the vm — worth fixing beyond the test, because that call sits inside a timer where a ReferenceError is swallowed and would take the recovery with it. It reads through `window.` like terminal-ui.js does with its own constants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
05c788ce9d |
fix(watching): close the review findings on the label and its window
A dual review (Codex CLI and Claude's code-reviewer, same diff, same brief) found the trust boundary weaker than the comments around it claimed. Eleven findings, all applied. The two blockers were both about who can write the row the label is read from. Claude's window covered two rows, and the second one is the status line, whose command a session running with permissions bypassed can write into its own `.claude/settings.json` — so an agent could print `· 1 monitor ·` onto a row of its own and silence its own idle alert. The default window is one row now, which is the footer and nothing else, and the constant says why. Separately, the label reached `data-tab-meta-sig` unescaped while the row is installed with innerHTML, which is an injection sink for any config-supplied pattern whose capture group is permissive; it goes through escapeHtml() like every other untrusted string in that file. The Codex entry could not be fixed the same way, and now says so. Its row is third from the bottom only while a terminal runs; with none running that slot holds the last row of the transcript, so matching the complete row (with the `/stop to close` tail, window narrowed to three) raises the bar without closing it. What contains it is `hooks: 'none'`: no hook event from a codex session reaches notePrompt(), so a forged label costs a wrong badge and cannot quiet an alert. The registry comment, `docs/cli-registry.md` and the test all state that rather than claiming a guarantee the code does not have. Also from the review: the TUI header badge no longer counts an acknowledged item, which was the same gate the classifier fix already went through and was wrong for human acknowledgement too; the TUI approval card reads the quiet reason and drops to a new `info` tone instead of asking for a reply; the badge carries an aria-label, because the phone it was built for has no hover target; the schema refuses `watchingLines` without a `watchingLine`; and the pattern and its window are resolved together rather than one memoized and one not. Documentation moved with it. The mechanism now lives in `docs/architecture-invariants.md` with CLAUDE.md keeping the rule and a pointer, `docs/wiki/Notifications-And-Approvals.md` tells users why a session stopped buzzing, and both that page and the changeset name the limitation neither did before: a question asked in plain prose is not a dialog, so it is silenced along with the false alarms while background work runs. Verified live again after the narrowing, on an isolated beta: a Claude session reported `1 monitor` and took its idle prompt acknowledged, and a Codex session reported `1 background terminal` against the full-row anchor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e1e7dc5bd8 |
fix(terminal): Ark0N's read of the #464 geometry work
Five items, two of which he could only see by running it, plus six smaller ones. Taking the two blockers first, because both were wrong in ways the existing tests could not catch. **Adopting the PTY's rows put the CLI's input line off-screen.** A phone that took a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container; xterm's own viewport then had nothing to scroll, so the bottom of the frame sat below the container with no gesture able to reach it. Output visible, typing invisible, for as long as the desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now: width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping the local row count keeps the composer at the bottom of a viewport that scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and the input line is inside the box. **`capture-geometry-retry.browser.test.ts` failed, and CI could not see it** because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp — `getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this work removes at the source, so it can never hold again at any viewport. The case survives on its own terms: a pane already drawing at the requested size must not be replayed. Its premise is now the #464 invariant itself, that the floored report and the terminal agree, which is a stronger guard because the clamp coming back fails it here rather than silently restoring the replay loop. The helper docblock that repeated the old premise is corrected too. **A session with no pane reported 120x40 and the client adopted it.** `resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and nothing seeds them from the spawn geometry, so a dead-pane session still held the constructor defaults — clicking that tab resized the browser terminal to 120x40 and, on anything narrower, claimed another device owned the pane when none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows` getters are deleted rather than left available to be misused again. **The 40-column floor clipped the pane with nothing able to reach it.** The affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm and the PTY agree throughout, the terminal is simply wider than the box. It keys on what does not FIT now, MEASURED (`.xterm-screen` against the container, on the next frame, because the screen takes its width with the render) rather than derived from cell arithmetic. Measured at 360px: font 24 applies 40 columns and paints 560px, and all 200px of the overhang is reachable. `.pty-oversized` is renamed `.term-overflows-x`, because after this the old name describes only one of the two causes. **"Scroll sideways" did not work on touch for the sessions it targets.** `touch-action: pan-x` is cancelled before it starts by the `preventDefault()` `touchstart` calls on every 'content' tap. The terminal's own touchmove handler pans the container now, with the axis locked once per gesture so a diagonal cannot pan and scroll at once, and the CSS grants no `touch-action` at all — handing the browser a pan AS WELL would move the pane twice for one finger on the taps where that preventDefault does not run. Measured under real touch dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the buffer does not move with it, and a vertical swipe still scrolls the scrollback. Three defects in the above, found while checking it rather than by being told: - `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is true of a container that is not a scroller — a sideways swipe would have locked the axis, done nothing, AND suppressed the vertical scroll it should have been. Gated on the class as well. - The notice advised scrolling sideways whenever the PTY was wider, including when it still fitted and nothing scrolled. It is gated on measured overflow, and on a comparison against the width this container WOULD request rather than the one it currently holds — once adopted those are equal, so the second question answers itself false while the condition is still true. - `_syncTerminalOverflowAffordance` could throw out of `document.getElementById` before reaching its try block. It runs off every geometry change, so a cosmetic affordance could have taken the resize down with it. The smaller items: - `docs/architecture-invariants.md` no longer explains the equality guard as a clamp signature; it records what the clamp used to do and why it cannot any more. Edited by hand — that file is outside the Prettier glob, and letting Prettier near it rewrote eleven unrelated emphasis markers. - `throttledResize`'s HTTP fallback reads the reply. It is the path where a declined resize is least likely to be noticed, because no socket means no `{"t":"zc"}` frame either. - The changeset covers the whole release: the geometry work, the queued replay clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket output-gap reconcile, the build-generated service-worker precache and per-build cache key, and the crash-trail hygiene. - `@xterm/headless` is declared in the root devDependencies instead of being reached through workspace hoisting. - The output-gap marker is cleared after any response arrives, not only when the capture was non-empty: a server that answers with an empty capture HAS reconciled us, and leaving the marker set refetched on every reconnect. - `e587d845`'s message claimed a test asserted the failed-load copy against the built asset. It did not — that assertion lived in a probe deleted with the other scratch scripts, so the claim was false when it was written. There is a real test now, and it reads the source rather than `dist/`, because `dist/` is not committed and a test that skips when it is absent would pass for the wrong reason in CI. `Session.ptyGeometry` gets behavioural coverage against the real class in `session-resize-arbitration.test.ts` rather than a source guard, including the contrast — a pane that does exist still reports, and still follows a resize — so "always null" would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e587d84590 |
fix(terminal): make the failed-load notice fit the narrowest terminal
A third pass in a real browser, at the widths this app actually renders at. The notice a failed history load writes into the blanked pane was one 70-character sentence. At 430px that exactly filled the line; at 320px it wrapped and left a lone '.' on a line of its own. The floor this app will render at is 40 columns — reachable today by raising the font on a phone — so the notice is three lines now, none over 25 columns, one fact each: what failed, that the session is still alive, and what to do. It says RELOAD rather than "reopen the tab" because `selectSession` early-returns when the session is already active, so clicking the tab you are already on retries nothing. The earlier wording named no next step at all, which left a mostly-empty terminal and no way out of it. CLAUDE.md no longer cites "758px reachable to the right" as evidence: that figure is a property of the test content, not of the fix, and the file's value is that a reader can trust a claim without re-deriving it. What is pinned instead is the invariant that survives any content — the full pane width is reachable, and removing the class returns scrollLeft to 0, so a resolved mismatch cannot leave the pane parked off-screen. Verified at 430, 360 and 320px against the shipped bundle, with the test asserting the built asset carries the copy so an edit that never reached the build fails rather than passing on the source's wording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
abf1d1f1ca |
fix(terminal): the PTY and the browser terminal must never disagree about size
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen". The screenshot is not a dropped frame or a frozen renderer — it is arithmetic. Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the rows it believes that frame took. A browser terminal of a different width makes each logical line occupy more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased: doubled lines, and short tool summaries sitting inside longer prose rows with the prose's tail still visible. Reproduced against this repo's own xterm before changing anything — a 120-column PTY against a 62-column terminal renders every wrapped line twice. `test/ terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching widths beside it, so the assertion cannot be satisfied by code that fixes nothing. Four ways the two drifted apart, none of them observable from either end: 1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every server-facing path reported those floored at 40x10. Measured in Chrome at 430px: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. Three call sites each did their own fit-then-floor, and two re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly there, so the container had moved. 2. `throttledResize` (keyboard up) and `sendResize` (session detached into its own window) reflowed locally and withheld only the SIGWINCH. That is the one combination that cannot be right: a reflow nothing is rendering for buys nothing and costs correctness. Both now withhold everything, and the keyboard's settle timer still sends the one resize that stops the PTY going stale. 3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry change — and told the server nothing at all, so raising the font on a phone left the CLI wrapping at the old column count. 4. `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim, and said nothing, because resize was write-only. `syncTerminalGeometry()` is now the one function that may change the terminal's size: it fits, floors and applies as a single step, so the numbers xterm holds are the numbers the server is told. A test sweeps every module for a bare `fit()` on the main terminal, and finds exactly one — the owner's own. For (4) the client cannot win, so it is told the truth instead: both transports answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal that keeps a shape the PTY refused does not render "too narrow", it renders garbled. Adopting can leave the pane wider than the screen and the container is `overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as long as the mismatch lasts: correct-and-reachable beats correct-and-clipped beats garbled. That rule sets both overflow axes and its own `touch-action` because mobile.css loads later and sets `.terminal-container { overflow: visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y computing to `auto` and hand the browser a vertical scroll container the terminal's touch handler knows nothing about. Verified in Chrome at 430px against a live server, with a desktop client holding the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y: hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own vertical scrolling. The pre-fix build was measured in the same harness for the control. Two things this deliberately does not do. It does not change who owns the pane size — the desktop still wins, and `_startMobileResizeRetry` still takes it back once that goes idle. And `throttledResize` still holds the PTY's shape for the whole keyboard animation rather than sending a SIGWINCH per step; that decision predates this and was not re-tested here. Also in this commit, Ark0N's third-pass review items on #431: - The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes` (32MB) — the largest body the frontend asks for anywhere. One carried no deadline at all and the other got the 15s tail budget. Both now take the full-history budget. - A `?full=1` capture that outruns its deadline falls back to the bounded tail. The pane is blanked before that fetch, so an abort used to leave a black rectangle, discard the queued live output and never reach `_connectWs`. A failed load now still opens the socket, says one dim line where the content would have been, and clears the tab's spinner — which nothing did, so a failed select left `aria-busy="true"` set forever. - `_wsOutputGapSession` is cleared at the repaint that settles it, not in a `finally` that also ran on the catch. A reconcile that threw, or hit the new deadline — the flaky link the marker exists for — dropped the gap with nothing to retry it. `ws.onopen` no longer clears it up front either. - The replay-clear invariant is pinned in the gate, which is the drift this PR exists to fix: `_resetTerminalForReplay` must be a queued write and nothing else, and no module may blank the terminal with a `clear()+reset()` pair. - `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was never declared, and that is the one function in the app that must not throw. - panels-ui's two kill-all clears route through the same helper, and the xterm-version guard's comment says "resolved lockfile version" rather than "dependency RANGE", which is what it has pinned since the last round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
abd39318e6 |
fix(terminal): deadline must cover the body, precache must ignore the cache-bust query
Review fixes. Two of these are defects in the previous commit.
1. The fetch deadline only covered time-to-headers. `await fetch()` settles on
response headers, so clearing the abort timer in a finally around it left the
body — the multi-megabyte `?full=1` capture the deadline exists for —
completely unbounded; it only ever bounded a server that accepts a connection
and never replies. Measured against a server that sends headers immediately
and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, timer
cleared there, body completed at 4026ms unaborted. Now the body is read
inside `_fetchTerminalCapture`, which returns {json, headers, headersAt} —
headers because two callers read server-timing, headersAt because those same
callers measure header-vs-body time and can no longer observe that moment.
`_terminalCaptureInflight` is scoped the same way, so a body still streaming
counts toward a capture starting beside it. Same test now aborts at 1005ms.
2. The precache could never be hit, and the previous commit made that expensive
rather than free. `renderIndexHtml` runs `cacheBustAssets`, which appends
`?v=<mtime>` to every same-origin .js/.css reference INCLUDING content-hashed
names — confirmed against a running instance:
`vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101`. `caches.match` is
query-sensitive, so entries keyed on the bare hashed path were unreachable;
deriving the list from the manifest turned cheap 404s into ~1.3MB downloaded
at every install that nothing could read back, once per deploy now that
CACHE_NAME rotates. The fallback match takes `{ ignoreSearch: true }`, which
also lets runtime-cached entries survive an mtime change.
3. `_wsOutputGapSession` was only cleared in ws.onopen, so paths that already
repaint the buffer left it set and the socket replayed everything a second
time. `selectSession` loads the buffer and only THEN calls `_connectWs`, so
neither the _isLoadingBuffer nor the _terminalRefreshOwner guard applied.
`_markTerminalBufferReconciled()` is now called from _onSessionNeedsRefresh's
finally, from selectSession after its load, and from _cleanupSessionData.
The scope claim was also wrong and is corrected in the comment: when the
network drops, SSE drops with it and handleInit's keepTerminal branch already
reconciles. The genuinely uncovered case is the WS dying while SSE stays up,
where _onSSETerminal discards SSE terminal frames until _wsReady flips in
onclose — up to the ping+pong window of output nothing writes.
4. CLAUDE.md said "all of them measured rather than reasoned", which the PR's
own "not verified" section contradicted. Split explicitly: the replay race is
measured, the watchdog mechanism is verified against xterm 6.0.0 under jsdom
(field path resolves, a forced stale handle makes refreshRows a no-op, the
kick schedules a fresh frame), and the iOS rAF-discard premise is reasoned
and still wants a device. Adds the two missing entries — the WebSocket
reconcile and the sw.js/build.mjs "keep these in sync or the build throws"
contract.
Also: test/xterm-private-api.test.ts pins the RESOLVED lockfile version instead
of the declared `^6.0.0` range, which was the wrong assertion in both directions
— a real upgrade to 6.4.0 can rename a private field while resolving inside the
range, and an innocuous range edit failed while changing nothing installed. And
test/sw-precache-manifest.test.ts now parses HASHABLE out of scripts/build.mjs
rather than hand-copying it, which was the same drift this PR exists to fix; the
parse is guarded against silently matching nothing.
The deadline fix has a behavioural test against a real socket plus a source
guard asserting `await res.json()` precedes the finally — verified to fail when
the helper is reverted to the old shape, so it is not vacuous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c0422c4e21 |
feat(terminal): renderer watchdog, atomic replay clear, fetch deadlines, reconnect recovery
Four ways the terminal can silently stop being correct — in each case the
buffer keeps updating, nothing throws, and the only recourse is a reload.
1. Renderer freeze after backgrounding. iOS DISCARDS scheduled rAF callbacks
when a PWA backgrounds, and xterm's RenderDebouncer only clears its
`_animationFrame` handle from inside that callback — so one drop leaves it
permanently set and every later refresh() early-returns. Parsing is
decoupled from rendering, so bytes keep filling the buffer correctly while
nothing paints. Codeman has exactly ONE xterm for the whole page load, so a
single backgrounding wedges it until a reload. Adds a 2s liveness poll and
`_kickRenderer()`, which does what the dropped `_innerRefresh` would have.
2. Replay clears raced live output. xterm's write() is async-queued while
reset() is synchronous and, per upstream, "does not clear input buffers and
does not reset the parser" — so bytes queued before a reset are parsed after
it and fuse into the snapshot. Verified against the real xterm 6 here:
write('p8'); reset(); write('rmissions') renders "p8rmissions". The main
path was already safe via a queued erase; the needsRefresh and clearTerminal
paths were not. All three now share one queued `\x1bc` (RIS), which unlike
3J/H/2J also resets modes, charsets, scroll regions and SGR state.
3. Output lost on WebSocket reconnect. Input frames carry seq+cid and are
delivered exactly once; output frames carry nothing. ws.onopen re-sends dims
and flushes queued input, and needsRefresh only fires on external-CLI
startup and SSE backpressure drain — never on reconnect. Output produced
while offline was simply absent afterwards. Interim fix: reaching onclose
means the drop was unintentional, so the session is marked and the next open
reconciles from the server buffer. Sequencing output is the follow-up.
4. Terminal captures had no deadline. No AbortController anywhere in the
frontend, including `?full=1`, which the code itself calls "unbounded-ish
work: at the default history limit it can be megabytes". Adds a budget that
scales with full-vs-tail and with captures in flight, degrading to a plain
fetch where AbortController is missing.
Also: the service-worker precache was dead — the build content-hashes assets
but sw.js listed pre-hash names, so 15 of 23 entries 404'd (verified against a
running instance) and cache.add().catch() hid it. Offline still worked via
runtime caching, but CACHE_NAME was a constant so activate's cleanup never
deleted anything and every past release's assets accumulated. Both are now
derived from the build manifest. Crash-trail entries are flattened and capped,
since they are joined with \n into one value and one call site interpolates a
server-controlled WS close reason.
The watchdog reads xterm privates — there is no public API. Every access is
optional-chained so a shape change degrades to a no-op. `_renderService` only
exists after open(), which needs a real DOM, so the gate cannot assert the
field path; test/xterm-private-api.test.ts pins the dependency range instead.
Tests: 23 new (terminal-resilience, sw-precache-manifest, xterm-private-api),
all pure/static so they run in the gate, which excludes the mobile suite. One
static source guard in history-truncation-notice updated for the renamed call;
the behaviour it pins is unchanged.
Not verified: no browser available, so no runtime reproduction of the freeze
and no real-device test of the reconnect path. Both warrant a device pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
74884a20eb |
feat(approvals): let a watching session keep quiet, and fix the tui gate
The badge alone left the row in NEEDS YOU, which is the thing the issue was about. The fix is the alert that does not fire. An idle prompt from a session that is watching its own background work now opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to `notePrompt()`, which sets `acknowledgedAt` and records why in a new `acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", and the prompt itself stays pending, answerable and available as Read My Mind context. A wrong label therefore costs a card that does not blink, never an alert that was never created. Every surface follows from that. The broadcast carries the reason, so a live page declines to arm the tab alert and raises no desktop notification. The push is skipped, since a false alarm is hardest to ignore on a phone. A reloading page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And `classifySession()` now reads it too, which is a pre-existing bug fixed here: acknowledging on one device cleared the alert everywhere except `codeman tui`. It re-arms for free, because the next idle prompt supersedes the item and is built fresh. Only `idle` is eligible, so a dialog that blocks the agent still goes red whatever else it started. The label is pane-derived and therefore prompt-injectable, so it is now read from the last two rows of the screen only, with Claude's pattern anchored on the `·` its footer joins items with, ANSI-stripped and length-capped at the source. An agent that prints `· 1 monitor ·` into its own output finds no match. Verified on an isolated beta: a session that armed a monitor took its idle prompt acknowledged with no alert on any surface, wore the badge, and showed "quiet, watching 1 monitor" on its still-answerable card; the same session with the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts` pins both directions across all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f2cde2db7 |
feat(session): say when a session is watching its own background work
An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn. The pane then falls quiet, Claude Code's idle_prompt notification arrives a minute later, and every surface files the session under NEEDS YOU with nothing for a human to answer. Claude states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`). That row is now `capabilities.workDetect.watchingLine` in the CLI registry, guarded by compileVersionRegex() like every other config regex, and the idle probe reads it off the capture it already takes: `watchingLabel()` in session-activity.ts searches the last five lines only, so a session that PRINTS "1 monitor" is not mistaken for one running it. The label lands on Session.watching and rides toLightDetailedState() out to every surface. The phone overview, the desktop home rail and the rich sidebar rows wear it as a `watching` badge in the accent colour, beside the state pill and never in place of it: an agent can arm a monitor and ask a question in the same breath, and only the pill says which. Verified end to end against a throwaway session on an isolated beta instance: the payload carried `watching: "1 monitor"` once the turn ended, the badge rendered next to a yellow `waiting` pill, and both cleared when the monitor died. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c67c130caa |
feat(web): mark a session tab whose agent has exited
The tab now reads "exited (137)" beside the session name, drawn from the
`paneExit` field the server publishes. `applyPaneExitBadge()` owns the DOM
work, called from the incremental render path — the only path a live session
ever takes, since going from live to exited adds and removes no tab and so
never reaches the full rebuild.
An unknown answer draws nothing. A death tmux could not explain reads "exited"
with no number rather than "exited (0)", so an unexplained death and a clean
exit do not look alike. A signal death reads "exited (signal 9)".
The badge carries `data-i18n-skip`, like the status pills: it is generated
text, `i18n.js` walks inserted content, and a dictionary entry added later
would fight the renderer, whose in-place comparison is against English.
The tab also carries a `tab-agent-exited` class that mutes the status dot. That
dot is drawn from `status`, which stays `idle` or `busy` for an exited pane as
the issue requires, so without this a green or pulsing dot sits beside a badge
saying the agent is gone — the first thing a tester asked about. `status`
itself is untouched, so this is a rendering rule only. The CSS excludes the two
alert classes by hand, following the convention the rich-rail dot rules
document: a dot turning red or yellow because a session is blocked on a human
outranks "the agent exited".
The tab keeps its click behavior. X still closes it, and nothing here closes,
sweeps or restarts anything.
`docs/architecture-invariants.md` gains the mechanism under "Session data and
lifecycle", where every comparable one already lives: what the tri-state means,
the four shapes it is absent for, why the watcher cannot ride the stats
collector, why an absent `#{pane_dead_status}` is not 0, and the three things
that must never happen to an exited pane.
Refs Ark0N/Codeman#446.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
773b405429 | feat(mobile): add manual prompt composer | ||
|
|
1a99b5836c | Merge pull request #430 from opticon454/custom-model-run-menu | ||
|
|
c376534a50 |
fix(run,terminal): merge-time fixes for the Instance count stepper and capture geometry
#454: the behaviour the PR adds had no test, so a regression test drives runGrok() at tabCount 3 and asserts three quick-start POSTs with sequential w<n>-<case> names (verified to fail against master's session-ui.js). Each caller now reads the count BEFORE its opening banner and announces it there, the way runClaude() already did, so a launch no longer prints two headers and a launch with another session already active still says how many are starting. runClaude() calls the shared _readTabCount() instead of its own copy of the 1..20 clamp, and that helper optional-chains the element read, since hoisting it above each caller's try block would otherwise let a missing #tabCount throw where the launch-error path cannot report it. #435: sizeMovedUnderLoad derived from data.source alone. `mux-visible` is not sufficient: a failed display-message cursor query makes capturePaneBuffer skip the snapshot repaint and return the raw capture, which the route still labels mux-visible, so a size that moved during such a load bought a full forced reload to repair a frame that was never positioned. It now tests Number.isFinite(data.captureRows) like its two siblings. Plus the invariants and CLAUDE.md lines promised on #435: a visible capture reports its geometry and omits it when nothing was positioned, the comparison runs on mux-visible only, and the replay is capped at one attempt and latches per session when it cannot converge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2c3ccdf030 |
Merge pull request #439
feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet |
||
|
|
95dc6fe944 |
fix(terminal): remember a geometry replay that did not converge
`resizeRetry` caps the recursion inside one select and says nothing about the next one, so a pane this browser cannot size reported the same mismatch on every select and bought the same failed repair each time: two fetches per tab switch for the life of the page, measured as a running count of 2, 4, 6 across three selects. That is the case this branch describes as happening every time rather than occasionally, a phone whose resize `Session.resize` declines while a desktop claim is live, and it is not the only one — any pane Codeman cannot size lands there, including one a second tmux client is also holding. Each wasted pass costs another `capture-pane`, which is `execSync` and blocks the server's event loop, plus a reset and chunked rewrite, a discarded snapshot and cache entry, and a dropped and reopened WebSocket. `_geometryRetryUseless` mirrors the existing `_fullHistoryRepullUseless`: a retry pass whose frame still does not fit adds the session, geometry that fits removes it, and the replay gate consults it. The proof has to come from a retry pass rather than a first one, because the retry ran at the size that stuck and the pane ignored it. Clearing on a fitting frame is what stops a pane that becomes sizeable again, once the desktop tab closes or its claim goes idle, from staying permanently unrepaired. The race case never reaches the latch, since it converges on its first attempt. The new browser case walks all of that: three selects reading 2, 3, 4 instead of 2, 4, 6, then a fitting frame, then a mismatch diagnosed afresh. Without the gate it fails on the second switch with `expected 4 to be 3`. Rebased onto master, which has moved to 1.30.0 and taken #436. The one conflict was `config/test-suites.ts`, where both branches appended a glob to `BROWSER_TEST_GLOBS`; both are kept. Everything else merged clean, #436's own changes to the same buffer-load path included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
383f834704 |
fix(terminal): flush unsent local echo before the geometry replay
On a touch device the characters the user has typed live only in the local-echo overlay until Enter; they have never reached the PTY. The replay re-enters `selectSession` with `forceReload` on the session that is still active, and that branch nulled `activeSessionId` before `_cleanupPreviousSession` ran. The flush there is guarded on a session it can still see, so it was skipped, and the unconditional `_localEchoOverlay.clear()` that follows took the characters with it. Measured in chromium against the previous head: typing into the overlay and then making the call the replay makes left `pendingText` empty with nothing crossing into the delivery layer on either transport. The flush moves into `_flushLocalEchoTo(sessionId)`, called from both `_cleanupPreviousSession` and the `forceReload` branch before it nulls the id. The session is a parameter because the two callers mean different ones: cleanup flushes to the tab being left, the branch to the tab being reloaded. This was reachable before this branch, through the one gesture that already takes the `forceReload` path on an active session. What is new is that nothing the user does triggers it. The replay fires on its own the moment a tab switch finishes, which is exactly when someone typing into a still-loading terminal has text in the overlay, and on a phone beside an active desktop tab that is every tab switch. A seventh browser case pins it: it forces the overlay on, since headless chromium reports no touch support and the case would otherwise pass vacuously, asserts the typed characters really are sitting unsent, then triggers the replay and asserts they reached the session. Without the fix it fails with nothing delivered at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e0d4477edc |
fix(terminal): keep the geometry replay to the pass that can converge
Three follow-ups to the source gate, each one measured rather than reasoned. A pane already drawing at the size the client just requested is left alone. The replay runs at `dimsAfterLoad`, so it can only change what is on screen if the pane was drawing at some other size; when the reported geometry already IS that size, the second pass captures the identical frame and pays a full reload to do it, including a visible re-flash, a dropped and reopened WebSocket and a deleted xterm snapshot. That equality is the signature of a clamp rather than a race: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does not, so a terminal narrower than 40 columns or shorter than 10 rows reports a pane permanently bigger than itself and replayed on every tab switch without ever converging. A race never produces the equality, since its premise is that the pane was still at the size it was asked to leave. The declined-resize case does not produce it either, so that one still costs the single capped attempt and needs the pane-ownership question this does not touch. The full-history re-arm is unreachable and now says so. A pass that consumed the flag sent `full=1`, and the route answers `full=1` with `mux-full-history` or `history`, never `mux-visible`, so the source gate already rules out every such pass. The line stays for the invariant, but its comment no longer reads as if a page load retries, and the suite pins that it does not. The response no longer reports geometry for a body that carries no capture. The full-history path writes `capturedGeometry` from the cursor query and then returns '' for a pane holding nothing visible, which drops the source to `history` with the geometry already recorded: a `full=1` request whose capture reported 100x50 and returned nothing answered `source: "history"` with both fields set. Nothing acted on it, because the client ignores geometry on any other source, but the field said a frame had been drawn at a size when none had. The browser stub now derives `source` from the request the way the route does, rather than answering `full=1` with `mux-visible`, which the route cannot produce. Each case reaches a visible-frame response the way production does, by not being the first select of the page. Three cases pin the new behaviour and each fails without its guard: the clamp case sees two fetches instead of one, the scope case and the full-history case both see a replay the gate forbids, and the width case sees one fetch instead of two. The changeset now describes the change from 1.29.x rather than the difference between the two commits on this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5cfb98fb8b |
fix(terminal): compare capture geometry only on a visible-frame response
Only a visible-frame capture positions its rows absolutely, so only that frame can be damaged by a terminal of the wrong size. A `full=1` body is linear scrollback closed by a relative cursor move, which is relative precisely so the browser's row count need not match the pane's, and a `history` body is the byte stream, which carries no row alignment to protect. The geometry comparison ran on all three, so it fired most often on the one response it cannot help: `_fullHistoryLoaded` is empty on the first select of every non-shell session per page, and a session whose pane a desktop tab holds too tall to ever fit then paid a second whole-scrollback capture, reset and replay on every page load and every first tab switch. `framePositionsRowsAbsolutely` gates both the captured-geometry comparison and `sizeMovedUnderLoad`. A size that moved under a byte-stream or scrollback replay is healed by xterm's own reflow plus the SIGWINCH the trailing `sendResize` already sends. A pane WIDER than the terminal damages the same frame a second way, so `captureCols` is now compared rather than only logged. `formatPaneSnapshot` paints each row out to the pane's own width, so a narrower browser wraps every painted row, and the wrap on the last one scrolls the whole frame up by a row. The terminal response no longer falls back to `session.ptyCols`/`ptyRows` when the capture reported no geometry. The cursor query is what produces the absolute addressing in the first place, so a capture that lost it returned a raw frame that was never positioned, and a byte-history response was never positioned either. Naming the session's own PTY size there described a frame that does not exist and invited a repair for damage that is not present. `_ptyCols` is also written only by `resize()` while the PTY is spawned at the size queried from tmux, so it can be wrong on its own terms. Both fields are now absent instead, and the `Session` getters added for that fallback go with it. Two browser cases cover the new behaviour and each fails without its fix: a `mux-full-history` response with both dimensions mismatched asserts one fetch (two without the gate), and a `mux-visible` response wider than the terminal but short enough to fit asserts two (one without the width comparison). Corrects a claim in the comment above `capturedGeometry` in tmux-manager.ts. Both replay paths do not address rows absolutely; the full-history one ends in a relative move, which is the whole reason the gate is right. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3edf9aae2f |
fix(terminal): replay a pane capture at the geometry it was taken at
A visible-frame capture repaints each row at an absolute position, counting up to the pane's height. A terminal shorter than that clamps every address past its own height onto its last line. The overflow rows then overwrite one another, and the rows underneath are lost. Replaying a real 50-row capture into a 30-row terminal rendered 28 lines of a 45-line command and drew the frame twice. Nothing in the response said what height the frame was built for, so the client could not detect this. A capture now reports the geometry it was really taken at through `capturedGeometry` on `PaneCaptureOptions`, and the terminal response carries it as `captureCols` and `captureRows`. When the captured pane is taller than the terminal, or the size that produced the capture did not survive the load, `selectSession` replays once at the size that stuck. `resizeRetry` caps that at one attempt, so two competing fits cannot trade replays forever. The retry re-arms the full-history flag only when the pass that ran had consumed it. A tab switch takes the bounded tail, so its retry takes the tail too: clearing the flag unconditionally would upgrade that switch into a fresh scrollback capture the user never asked for, which the route's own comments put at tens of megabytes. What this repairs is a capture that won a race against the resize meant to precede it. It does not repair a capture whose pane was too tall because `Session.resize` declined the resize outright, which it does for a small viewport while a desktop viewport's size claim is live. The retry re-sends the same declined resize and captures the same pane, and `resizeRetry` then stops it. Repairing that means changing who owns the pane size, which is a policy question this does not touch. The reported geometry still helps there, because the client can see the mismatch at all rather than being blind to it. Follows #395, #396 and #397, which fixed the other ways the replayed frame and the terminal could disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e271a65e79 |
Merge origin/master into feat/remote-host-wake
Resolves CLAUDE.md count tables (route counts recounted on the merged tree: 235 handlers, sessions 37) and keeps both the host-wake and the reboot-restore banner in index.html. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG |
||
|
|
3cdb4bf42e |
docs(terminal): the merge-time notes promised on #436
The four edits the review said would be folded in at merge, none of them code: the changeset becomes one user-facing paragraph, since it is what CHANGELOG.md and the release notes print; the `_bufferLoadFinishOpts` comment now names the second contributor to the duplicate window (`captureActivePaneBuffer` is `execSync`, so anything painted into the pane before the server read it is in the capture and is broadcast after the reply) and says why a `history` payload keeps the pre-existing discard when its exposure is the same; the `_finishBufferLoad` doc block moves from above `_beginBufferLoad` onto the function it documents; and the test file's header describes both rules the file now pins instead of only COD-144. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |