Compare commits

...
Author SHA1 Message Date
Codeman maintainer 7eaa39a67e chore: version packages (1.41.1)
One consolidated patch changeset for the 24 PRs in this release (the PRs'
own changesets folded in), CHANGELOG, package and plugin manifest versions,
and the CLAUDE.md version line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 19:22:13 +02:00
Codeman maintainer 9faf567d41 Merge pull request #610: fix(approvals): treat DeepSeek permission prompts as terminal-only alerts (author's Prettier follow-up) 2026-10-13 19:22:07 +02:00
Codeman maintainer 91750d4bd5 docs(tiles): Detach Tiles known limits (#584 merge-time fix)
One stored grid per browser shared by every Codeman window, no cross-profile
notice (BroadcastChannel is per profile), and the user-activation window that
blocks a pop-out from a drag-out held too long.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:59:56 +02:00
Codeman maintainer 63ac10dcc0 fix(tiles): docking a tile asks any pop-out of that session to close (#584 merge-time fix)
_dockForeignTile posted close-request only when this window knew the session
was popped out. A pop-out opened from another dashboard window was not in this
window's detachedSessions, so it stayed open beside the newly docked tile and
two views sized one PTY. The request now goes out for every docked id;
dashboards ignore it and only that session's pop-out reacts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:59:39 +02:00
Codeman maintainer 48e91fc1d9 fix(tiles): a claim that lands mid-drag lets the dragged tile go (#584 merge-time fix)
_onTileAdoptedElsewhere acted only on a tear-off, a pending tear-off or a drag
that just ended. A header drag carries the tile id even when this window has
Detach Tiles off (no tear-off), so another window could dock the tile and its
claim could arrive before this drag ended; the tile then stayed here as well.
The header drag (_draggedTileId) now counts as dragging too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:59:24 +02:00
Codeman maintainer 3975089af3 fix(tiles): a tile docked in another window leaves this window's stored grid alone (#584 merge-time fix)
There is one stored grid per browser (codeman:tile-grid), and the window that
docked the tile has just written it. When this window then hears the claim and
drops the tile, removeTile (or, for its last tile, closeTileGrid with
keepStored: false) overwrote or forgot that stored grid. The removal now runs
under _tilePersistHold, and the last-tile close keeps the stored grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:58:22 +02:00
Codeman maintainer 9ad43fbd88 Merge pull request #584: feat(tiles): move tiles by hand, and detach a tile into its own window 2026-10-13 18:57:50 +02:00
Codeman maintainer 5e83cd3885 fix(html-view): a ~/ HTML link renders as a page too (#547/#604 integration)
#604 makes `~/` links clickable and passes them through unchanged, since only
the server knows the session host's home. The html-view mint read `~/x.html`
as workspace-relative and answered 404, so the preview fell back to the
source view. It now expands `~/` against this host's home, as the attachment
route does, and refuses it in a Docker case (that `~` is the container's)
with the same message as the attachment and folder routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:56:58 +02:00
Codeman maintainer 1172ef56f8 test(file-preview): every link form routes .md and .html to the preview (#547/#604 integration)
Pins the merge resolution: `~/`, relative (`./`, bare) and hard-wrapped
links to .md/.markdown and .html/.htm open the file preview (rendered
markdown, HTML page) exactly like rooted ones, while other text such as a
relative .json keeps the log viewer. Fails if the terminal's activate path
drops rendersAsPage().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:56:58 +02:00
Codeman maintainer 0996f99fbb Merge branch 'rel1411/html-md': render HTML files as pages (#547) and open .md from the terminal in the rendered viewer
Conflicts resolved against #604, #606 and #601:
- constants.js: the rooted link pattern takes html-md's `markdown` and
  `html?` with #606's m4v/ogv; #604's relative pattern gets the same list
  (link-provider-regex pins the two equal); #604's findFilePathLinks /
  folder helpers and html-md's rendersAsPage() are both kept, and
  window.CodemanFilePaths exports all of them.
- terminal-ui.js: #604's resolved `target` (relative, ~/ and folder links)
  now also goes through rendersAsPage(), so every link kind routes .html to
  the page preview; .md routes there through FILE_PREVIEW_EXTENSIONS.
- CLAUDE.md, api-reference, architecture-invariants: both sides' text kept.
- test/folder-grid-ui.test.ts (#604) stubs rendersAsPage() like the other
  panels-ui harnesses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:55:02 +02:00
Codeman maintainer b15033259a style(approvals): prettier on the two lines #610 wrapped (#610 merge-time fix)
format:check failed on src/web/server.ts and src/web/public/approvals-ui.js;
both assignments fit on one line at printWidth 120.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:29:09 +02:00
Codeman maintainer 0feb5658ca Merge pull request #610: fix(approvals): treat DeepSeek permission prompts as terminal-only alerts 2026-10-13 18:29:01 +02:00
Codeman maintainer 82d588a339 docs(mcp-sync): the alt config file is written when it parses (#599 merge-time fix)
Three comments said `altPath` is never written, but run() makes the alt file
the write target when the primary is absent. The behaviour is fine (a JSONC
file with comments fails the parse guard and is never written); the
comments now say so, so nobody loosens the path rule on that belief.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:28:34 +02:00
Codeman maintainer 1f41eacd87 fix(mcp-sync): read mcp.servers as a V1 server when it has a V1 type (#599 merge-time fix)
mcpTable took any record at `mcp.servers` as OpenCode's V2 container.
OpenCode itself does so only when the value is not a V1 server (`type`
local or remote). So a server named "servers" copied from another CLI was
written as V1, re-parsed as a container, failed the read-back guard and
failed the whole OpenCode target, and an existing V1 "servers" stopped
being read. The V2 entry now also wins a name collision, as in OpenCode;
sync never writes a name already defined, so the read-back is unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:28:15 +02:00
Codeman maintainer a8b8530722 Merge pull request #599: fix(mcp-sync): read Gemini bare url as http, OpenCode V2 mcp.servers, and opencode.jsonc 2026-10-13 18:27:53 +02:00
Codeman maintainer c3cee40f56 fix(mobile): a pasted ESC[201~ cannot end the bracketed paste early (#600 merge-time fix)
_sendBracketedPaste wrapped the text as is, so clipboard text carrying the
end marker (a pastejacking page can write one) closed the bracket early and
the rest was typed as keys, its CR running a command before review. The
paste markers inside the text are now dropped first; this also hardens the
Compose path, which shares the helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:27:39 +02:00
Codeman maintainer b7c18ab5b7 Merge pull request #600: fix(mobile): shell Paste inserts a multi-line block as one bracketed paste 2026-10-13 18:27:11 +02:00
Codeman maintainer 70e03b8ae7 fix(preview): relative path links take m4v and ogv like rooted ones (#604/#606 integration)
#606 added m4v and ogv to the rooted file-path link pattern for the new video
uploads; #604's relative path pattern copies that extension list, and its
test pins the two equal. The relative pattern now carries them too, so a
relative `clip.m4v` links like `/home/u/clip.m4v`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:26:46 +02:00
Codeman maintainer de06cb12d1 fix(i18n): drop the duplicate zh-CN Attach key (#606 merge-time fix)
ZH_CN already had `Attach: '附加'` (the tile grid's and remote discovery's
Attach buttons). #606 added a second `Attach: '添加附件'` later in the same
object literal, which won and turned every Attach into "add attachment".
The duplicate is removed; 附加 covers the phone toolbar button too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:25:49 +02:00
Codeman maintainer eaa9044520 fix(uploads): drain a refused video body before answering (#606 merge-time fix)
The ?kind=video branch called req.file(), which pipes the request into
busboy, then answered 400 (unsupported container) or 403 (upload dir not a
regular directory) without reading part.file. The server then stopped
reading the body, and a browser fetch, which reads the response only after
its upload is sent, hung on "Uploading 1/1 file" for an Android 3gp or an
.mkv. Both early answers now consume the part first, as the mislabeled
container path already did. (Reviewer-verified in Chromium: hang before,
400 in about 0.2 s after; inject and a Node client do not reproduce the hang.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:25:07 +02:00
Codeman maintainer 6c7d63d45e Merge pull request #606: feat(mobile): phone Attach button and camera-roll video uploads
Conflicts resolved against #605 (and #602's test mock):
- CLAUDE.md Prompt uploads: #606's rewrite is kept and #605's bounded
  workspace probe sentence follows the remote (SSH) refusal.
- test/routes/session-routes.test.ts: the remote-pane-history and
  bounded-path-probe mocks are kept beside #606's _resetPasteRateBuckets
  import, and both PRs' paste-image tests are kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:21:52 +02:00
Codeman maintainer 6bbfc44054 Merge pull request #605: fix(uploads): probe the workspace bounded before a prompt upload
Conflict resolved in test/routes/session-routes.test.ts: #602's
remote-pane-history mock and #605's bounded-path-probe mock are both kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:20:21 +02:00
Codeman maintainer 3d341f46a4 fix(i18n): zh-CN for the working folder row and its copy toasts (#593 merge-time fix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:19:17 +02:00
Codeman maintainer f0b762e745 fix(session-options): label a remote or Docker session's working folder correctly (#593 merge-time fix)
The row said the path is "on the machine hosting Codeman" for every session.
A remote SSH session's workingDir is the path on the remote host, and a
Docker session's CLI runs inside the container the host folder is mounted
into. openSessionOptions now sets the description from session.remote /
session.docker (the id it targets was added with the layout fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:19:17 +02:00
Codeman maintainer 771c23e261 fix(session-options): stack the working folder row so long paths fit (#593 merge-time fix)
The row was a plain flex `.set-row` with a non-shrinking actions column, so
the <code> took its full max-content width: the label column collapsed to
0px, the path overlapped the description and the Copy button left the row
(off-screen on a phone). It now uses the stacked `set-row-block` layout the
Session color row already uses, and the path shrinks and wraps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:19:06 +02:00
Codeman maintainer 0ff9b798af Merge pull request #593: feat(session-options): show the session's working folder with a copy button 2026-10-13 18:19:01 +02:00
Codeman maintainer 33c6cf5d20 chore(gesture): revert the stray gesture bundle path comment (#591 merge-time fix)
#591 rebuilt the committed gesture bundle in a worktree whose node_modules sat
in a sibling directory, so its esbuild source comment named that local path.
Nothing in the PR touches the gesture source; the bundle goes back to master's
bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:18:22 +02:00
Codeman maintainer 9b9752d997 Merge pull request #591: fix(sessions): restore a session's colour, pin and image watcher after a restart 2026-10-13 18:18:22 +02:00
Codeman maintainer 60fd71470c Merge pull request #590: fix(cases): show the real reason when Link Existing rejects a case 2026-10-13 18:17:49 +02:00
Codeman maintainer 836402c2c4 fix(terminal): a replay strips the 8-bit C1 form of OSC 52 too (#611 merge-time fix)
xterm.js still parses OSC 52 introduced by C1 OSC (\x9d) and ended by C1 ST
(\x9c). OSC52_STRIP_RE matched only the 7-bit forms, so a direct-PTY session
that emitted the C1 form re-copied old text on every replay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:17:18 +02:00
Codeman maintainer f073742cf7 fix(terminal): an OSC 52 copy hands focus back to the terminal (#611 merge-time fix)
When navigator.clipboard is missing (plain HTTP, install.sh's LAN option) or
refuses the write (an unfocused window), _copyText falls back to a hidden
textarea whose select() takes focus, and removing it left focus on <body>:
the next keystrokes after Claude Code's /copy went nowhere. The handler now
restores the element focused before the copy, as Auto Copy does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:16:56 +02:00
Codeman maintainer f3d7a86c16 Merge pull request #611: feat(terminal): copy OSC 52 clipboard writes from the PTY into the browser clipboard 2026-10-13 18:16:20 +02:00
Codeman maintainer 34b867025d fix(tabs): Escape from a flat-rail web-tab menu returns focus to the row (#582 merge-time fix)
#582 made the web-tab actions menu reachable on the flat rail (ContextMenu or
Shift+F10 on a role=tab row), but closeTabGroupMenu only looked for a tree
item or a Focus shortcut, so Escape dropped focus to <body>. A `.session-tab`
trigger now counts too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:15:21 +02:00
Codeman maintainer 5d31e42db0 fix(tabs): Follow parent again keeps a Focus pin (#528/#582 integration)
#528's browser followParent operation rebuilt the target ref as
{ kind, id } to drop its manual placement, which also dropped #582's
`focus` flag. The browser PUTs whole layouts, so the pin was erased on the
next save. The operation now copies the ref and removes only `placement`.
#528's row-menu test also expects #582's 'Add to Focus' entry now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:15:02 +02:00
Codeman maintainer a6ce52d3e7 Merge pull request #582: feat(tabs): pin tabs to a Focus section on the vertical rail
Conflicts resolved against #528:
- CLAUDE.md Owner tab layouts: #582's Focus sentences and #528's
  tabGroupId / spawnedTabsFollowParent sentences are both kept.
- i18n.js: both features' ZH_CN entries kept.
- tab-layout-browser.js: the section-model JSDoc documents #528's
  `options.parents` and #582's `focusRefs` return field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:13:03 +02:00
Codeman maintainer 5ac27f3eec fix(tabs): children spawned from one parent keep creation order (#528 merge-time fix)
With the follow setting off (the default), pinCreatedChild marked each new
child manual where normalization put it: right after the parent's FOLLOWING
block, which no longer holds the siblings pinned before it. A fan-out
therefore showed parent, w3, w2, w1 in the layout and the global
sessionOrder. The pinned child now steps past the siblings (and their
descendants) already after the parent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:10:49 +02:00
Codeman maintainer a38eab49cd Merge pull request #528: feat(tabs): place new, child and web tabs into groups
Conflict resolved in docs/api-reference.md: #607's prompt cache section and
#528's tabGroupId section are both kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:10:37 +02:00
Codeman maintainer 9c56f7823d fix(preview): a shell prompt's trailing $ or # stays out of its folder link (#604 merge-time fix)
The folder link pattern treats `:` as a boundary and allows `$`/`#` in a path,
so every default bash prompt (`user@host:~/proj$`, `root@c:/workspace#`)
became a folder link to a path that does not exist, and clicking it showed
"Folder not found". folderLinkPath now strips them, so the link opens the
prompt's cwd.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:09:08 +02:00
Codeman maintainer 076f62fa1b fix(preview): refuse a bare ~ folder in a Docker case too (#604 merge-time fix)
The Docker refusal on POST /api/sessions/:id/folders tested only `~/`, but
listFolder also expands a bare `~` to this host's home, so a Docker case
could list and serve the host home through it (the preview's "Show folder"
for a `~/x.png` link reached it with no typing). A bare `~` now gets the same
400.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:08:38 +02:00
Codeman maintainer 90034d07d7 Merge pull request #604: feat(preview): open ~/, relative and hard-wrapped paths; folder grid 2026-10-13 18:08:12 +02:00
Codeman maintainer 887ee7fe64 docs(telemetry): expected_rebuilds is no longer the compaction signal (#607 merge-time fix)
Two comments still described the abandoned expected_rebuilds rise rule;
setPromptCache no longer reads that field for the compaction stamp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:07:26 +02:00
Codeman maintainer 62227145dc Merge pull request #607: feat(telemetry): read out Claude Code's prompt cache state per session 2026-10-13 18:07:15 +02:00
Codeman maintainer 2e65daff83 style(terminal): re-wrap the _maybePageCliTranscript JSDoc to 120 columns (#527 merge-time fix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:06:35 +02:00
Codeman maintainer 0e11012470 Merge pull request #527: fix(terminal): page a hollow Codex transcript with PageUp/PageDown from the first wheel event
Conflict resolved in terminal-tile.js's @dependency header: #601's
SHELL_LOAD_BASE_QUERY/SHELL_LOAD_QUERY and #527's pageKeysForGesture and
app.tabAlerts are all listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:06:24 +02:00
Codeman maintainer 5b8313988f fix(terminal): no paneHistoryLines for a remote session (#583/#602 integration)
A remote session's local pane hosts the remote tmux client in the alternate
screen, so its history_size is always 0. #583 read that as "nothing to load"
and hid the partial-history notice, but with #602 a full=1 load reads the real
scrollback from the remote tmux. The route now leaves the field out for remote
sessions, so their notice keeps the byte-based wording and its Load full
history button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:05:30 +02:00
Codeman maintainer 628b6e5832 docs(remote): a frame that starts on a wrapped line's second half is not placed (#609 merge-time fix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:04:36 +02:00
Codeman maintainer aca358126d fix(remote): reset SGR where the remote history meets the remote screen rows (#609 merge-time fix)
#609 keeps remote screen rows between the remote history and the local
frame. Those screen rows come from a separate capture-pane in the remote
command list, encoded from the default state, so the history's last row could
leave a background or dim active over them. The history now ends with a
reset before the screen rows, as the rows above the frame already do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:04:29 +02:00
Codeman maintainer 239f15437b Merge pull request #609: fix(remote): close the row gaps at the join in a remote history load
Conflict resolved in tmux-manager.ts: #609's screen-aware
joinScrollbackAndFrame keeps #602's merge-time SGR reset before the frame,
and #609's join tests expect it. The #601/#602 route test now mocks the
remote read in #609's { scrollback, screen } shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:04:05 +02:00
Codeman maintainer f78b820302 fix(terminal): a remote shell load keeps the remote scrollback (#601/#602 integration)
A remote session's local pane hosts the remote tmux client, so it is always
in the alternate screen. #601's alt=recording fallback therefore threw away
the scrollback #602 had just spliced in from the remote tmux on every remote
shell load. The fallback now applies only when no remote scrollback was
read (the read failed, or the session is not remote).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:02:36 +02:00
Codeman maintainer d571c41fb1 fix(remote): reset SGR at the remote scrollback splice seam (#602 merge-time fix)
joinScrollbackAndFrame joins two separate `capture-pane -e` outputs. The
remote history's last row can end with attributes still active (a diff row's
background, dim tool output), while the local frame is encoded relative to
the default state, so its first plain rows inherited them in the browser. A
`\x1b[0m` now ends the scrollback before the frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:01:45 +02:00
Codeman maintainer f9f00bdb5e Merge pull request #602: fix(remote): load a remote session's scrollback from the remote tmux
A full-history load of a remote SSH session reads the scrollback from the
remote tmux in one bounded ssh round trip and splices it above the local
frame. A transport failure (ssh 255, timeout, unparseable output) starts a
30 s per-host back-off; a missing remote session is per-session only.

Conflict resolved in session-routes.ts: #602's remote scrollback read runs
before #601's capture helper and its alternate-screen fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:01:15 +02:00
Codeman maintainer 78ed6bf004 docs(terminal): only a full=1 pass without alt=recording is never mux-visible (#601 merge-time fix)
The geometry-retry comment in selectSession and the #435 invariant said the
route never answers full=1 with mux-visible; a shell load's alt=recording on
an alternate-screen pane now is. The conclusion holds (a useFullHistory pass
never sends it), so only the stated reason changes. Also lists
SHELL_LOAD_BASE_QUERY in terminal-tile.js's @dependency header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:59:20 +02:00
DDT 43568775e6 style: apply prettier to approvals-ui and server 2026-10-13 23:59:10 +08:00
Codeman maintainer 36f8afdaba Merge pull request #601: fix(terminal): load a shell from tmux's rendered history, not the byte recording
Conflicts resolved against #583 and #588:
- One history-depth field: #583's `historyLines` / `capturedHistoryLines`
  (reported as `paneHistoryLines`) is kept, and #601's lines cut now reads it
  instead of a second `capturedHistorySize`. #601's `alternateOn` /
  `capturedAlternateOn` joins it, and the one display-message format string
  ends `#{history_size} #{alternate_on}`.
- selectSession keeps #588's full=1 switch for panes with no scrollback, gated
  off remote and Docker sessions, and #601's SHELL_LOAD_QUERY for shells.
- computeHistoryTruncationNotice names a line count when paneHistoryLines is
  known (#583) and otherwise uses #601's remainder wording.
- docs: api-reference lists both historyLinesCut and paneHistoryLines; the
  notice invariant keys on history_size while the shell's automatic load is
  the one place alternate_on decides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:59:07 +02:00
Codeman maintainer 1855d9423b fix(terminal): remote and Docker tabs keep the byte tail on a switch (#588 merge-time fix)
A remote (ssh) or Docker session's local pane hosts a nested tmux client in
the alternate screen, so it always reports paneHistoryLines 0 while its byte
tail carries the real scrolled-off output. The new full=1 switch therefore
replaced their scrollback with the visible frame on every revisit. Those
sessions now keep the tail; the pinned test string and the invariants note
say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:49:43 +02:00
Codeman maintainer 7ba1e51c51 Merge pull request #588: perf(terminal): tab switch to a fullscreen Claude tab loads only its current screen
Conflict resolved in config/test-suites.ts: both new browser test globs kept.
2026-10-13 17:49:16 +02:00
Codeman maintainer b4c87360d3 docs(api): paneHistoryLines is absent when the pane geometry was not read (#583 merge-time fix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:48:21 +02:00
Codeman maintainer ee038ad83c fix(terminal): the history notice waits for the pull already in flight (#583 merge-time fix)
_smoothScrollBy and the touch paths call _maybeLoadMoreHistoryOnScroll on
every frame. Once the viewport sat at row 0, the next frame hit the real
pull's in-flight early return, so its reveal ran at once and the notice
showed the pre-pull tail state for the whole pull. A later frame now leaves
the reveal to the call that started the pull.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:48:11 +02:00
Codeman maintainer f2d5af2b4c Merge pull request #583: fix(terminal): partial-history notice only when there is history to load 2026-10-13 17:47:37 +02:00
Codeman maintainer dedf56e24b fix(agent-cli): httpRequest rejects when the connection drops mid-body (#615 merge-time fix)
The response listened for 'data' and 'end' only. A socket closed after the
headers emits 'error' on the response and never 'end', so the promise never
settled. A one-shot `codeman agent` just exits, but the long-lived
`codeman mcp` left that tool call unanswered forever.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:47:09 +02:00
Codeman maintainer 71647e090d fix(mcp): start_session returns the worker's id when only the readiness call fails (#615 merge-time fix)
After quick-start succeeded, a failed wait-output readiness call (a 429 from
the waiter cap, a network blip) became an isError result without the session
id, so the model could neither prompt nor delete the worker it had just
started, and a retry started a second one. It now returns the id with
ready: null and a note, as `codeman agent spawn` does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:46:08 +02:00
Codeman maintainer 35e21dd676 fix(mcp): keep the input seq strictly increasing within one process (#615 merge-time fix)
send_prompt and interrupt_session tagged input with seq = Date.now() under
one clientId. The server drops a seq at or below the last one it saw for that
clientId and still answers 200, so two inputs in one millisecond (parallel
tool calls) or a clock stepped back lost the second input while the tool
reported it sent. The seq is now max(last + 1, now).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:45:21 +02:00
Codeman maintainer 3072449c94 docs(mcp): forward CODEMAN_MUX and CODEMAN_SESSION_ID in the Codex config (#615 merge-time fix)
Codex starts stdio MCP servers with a minimal environment, so `codeman mcp`
under the documented Codex config never learned the session it runs in: the
self-protection and the children rule did not apply. Both Codex TOML snippets
now forward the two variables with `env_vars`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:44:25 +02:00
Codeman maintainer bade1f1966 Merge pull request #615: feat(mcp): codeman mcp, a Model Context Protocol server for any MCP client 2026-10-13 17:44:15 +02:00
Codeman maintainer 5205649889 docs(claude-md): the agent preamble is seeded for every CLI that takes the skill (#613 merge-time fix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:44:07 +02:00
Codeman maintainer e65b4f35e6 fix(cli): skill uninstall --case skips a symlinked skills dir instead of exiting 1 (#613 merge-time fix)
A shared-skills layout (`.agents/skills -> ../.claude/skills`) made
`codeman skill uninstall --case` report the symlink refusal as a failure and
exit 1 on every run, even with nothing left to remove. The refusal itself is
kept (nothing is written through the link); it is now reported as a skipped
directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:44:07 +02:00
Codeman maintainer 50a1b58d2b Merge pull request #613: Give the codeman agent skill to every agent CLI, not just Claude Code 2026-10-13 17:42:58 +02:00
Codeman maintainer 9e8a851a2c fix(spreadsheet-preview): filter and index a shared worksheet rels list once (#612 merge-time fix)
ExcelJS's worksheet pattern is unanchored, so several parsed worksheets can
share one sheetNo and therefore one rels list. repairExcelJsModel filtered and
indexed that list once per worksheet, which let a crafted admitted file run
past the 20 s preview timeout. Each distinct list is now filtered and indexed
once per sheetNo, and the relationship Type strings are built once.
SPREADSHEET_ASSET_VERSION is refreshed for the edited core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:42:52 +02:00
Codeman maintainer 03a9259ae1 Merge pull request #612: fix(spreadsheet-preview): open openpyxl workbooks that have a comment, table or chart 2026-10-13 17:41:13 +02:00
Codeman maintainer ba5649bcd4 feat(html-view): open network access for rendered pages (owner decision)
Owner decision 2026-10-13 (#547): a rendered .html page behaves like a
file opened from disk in a browser. It may load scripts, styles, fonts,
images and media from any host and make fetch, XHR, WebSocket, beacon
and form requests to any host.

The /html-view Content-Security-Policy is therefore the `sandbox`
directive alone (HTML_VIEW_CSP, now exported and documented): the same
sandbox flags as before, never `allow-same-origin`, and no fetch
directive. It replaces Codeman's own `default-src 'self'` policy on these
responses. Everything that confines a page stays: the opaque origin (no
Codeman cookies or document, API writes with `Origin: null` refused, API
reads not readable cross-origin), and the capability serving only the
page's own folder under every path, symlink, dot, extension and
confinement rule.

A new test builds the route behind registerSecurityHeaders, as in
production, and pins the exact header: one `sandbox` directive with the
current flags, no `allow-same-origin`, no `-src` directive. The security
architecture, architecture invariants, API reference, CLAUDE.md and the
Working With Files wiki page now say what a page can do (reach the
network, read its own folder) and cannot do (Codeman's origin, cookies,
API, other folders), and that this is the owner's decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:39:36 +02:00
Codeman maintainer c1fde8e639 test(file-preview): give the stubbed harnesses rendersAsPage()
Three suites load panels-ui.js without constants.js and stub the globals
it uses (escapeHtml, CodemanBase). openFilePreview() now asks
rendersAsPage() from constants.js, so those stubs need it too; none of
their files is an HTML page, so it answers false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:23:25 +02:00
Codeman maintainer 1c19641280 style(html-view): prettier-clean the resolved-path dot check
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:20:18 +02:00
Codeman maintainer 79b9181932 feat(file-preview): open markdown clicked in the terminal in the rendered viewer
An in-workspace .md path clicked in the terminal went to the log viewer
(tail -f, a "streaming" header over raw text), because md was kept out
of FILE_PREVIEW_EXTENSIONS on purpose; every other surface already
rendered it. Owner decision 2026-10-13: markdown is a document to read,
so md and markdown join FILE_PREVIEW_EXTENSIONS and a click or tap opens
the file preview, rendered by default with the MD pill for source. The
main pane, every grid tile and the split's Pane B share the link
provider, so all of them route the same way. Other in-workspace text
(.log, .txt, .json, code) keeps the log viewer and its live follow.

FILE_PATH_LINK_PATTERN now also links `.markdown` and `.htm`, which
were not clickable at all.

Tests: file-preview-markdown.test.ts now asserts md and markdown ARE
routed to the preview (and plain text is not); terminal-touch-tap pins a
tapped .md, .markdown and .html opening the preview while .log, .txt and
.json open the log viewer, and a tile-registered provider routing the
same way against the tile's own session. Docs: CLAUDE.md, the
architecture invariants and the wiki (Working With Files, Mobile Guide).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:19:02 +02:00
Codeman maintainer 0168444fda docs(api): document the rendered-HTML routes
Adds GET /html-view/:cap/* to the list of endpoints exempt from the JSON
envelope, and a "Rendered HTML files" section covering the mint
(POST /api/v1/sessions/:id/html-view: body, response, capability
lifetime, every refusal and its status) and the capability-gated serving
route (what it serves, the sandbox CSP and other headers, Range, the
preflight answer, and the plain-text 404 / auth 401 refusals).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:16:23 +02:00
Codeman maintainer 5a13073657 test(file-preview): pin the HTML page view, its fallback and the Page pill
The HTML branch of openFilePreview(), the Page pill and the fall-through
to the source view on a refused mint had no test. The jsdom harness from
file-preview-markdown.test.ts now drives them with a stubbed fetch:

- a successful mint renders an iframe at the minted URL whose sandbox
  has allow-scripts and never allow-same-origin, with the pill pressed;
- a refused mint shows the source view with its Edit button and hides
  the pill;
- the pill flips page and source and persists
  codeman:filePreviewHtmlRendered, and with the pref off the preview
  opens as source without a mint request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:15:42 +02:00
Codeman maintainer 8f62faf130 refactor(file-preview): one rendersAsPage() for the terminal and the preview
What counts as an HTML page lived in three places: HTML_VIEW_EXTENSIONS
on the server, a /\.html?$/ regex in the terminal link provider and a
HTML_PAGE_EXTS set in panels-ui.js. The two frontend copies are now one
rendersAsPage() next to previewsInFileViewer() in constants.js, used by
the link provider (and through it every grid tile and the split's Pane
B, which share registerFilePathLinkProvider) and by openFilePreview()
and its toolbar. A new test pins it equal to the server's list.

toggleFilePreviewPage() now returns the preview load, so a caller (and
the coming frontend test) can await the flip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:14:53 +02:00
Codeman maintainer 7b4832b3e4 feat(admin): count revoked html-view capabilities in the logout audit line
The admin forced logout already revoked the user's rendered-HTML
capabilities but recorded only the cookie sessions and web-tab
capabilities it retired. The `user.logout` audit entry now carries
`revokedHtmlViews` next to `revokedWebviews`, and the revocation test
reads it back from admin-audit.jsonl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:14:00 +02:00
Codeman maintainer 7dd78d9d71 fix(sw): leave rendered HTML pages to the network
The fetch handler skipped only /api/, so a popped-out /html-view/ page (a
top-level navigation the worker intercepts) was put into CacheStorage
despite its `Cache-Control: no-store`, and the offline fallback could
serve it after logout had revoked the capability. /html-view/ is now
skipped like /api/. The new test runs the shipped sw.js against a stub
worker scope and drives its real fetch listener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:13:29 +02:00
Codeman maintainer 06f1e5ddc8 fix(html-view): answer the rendered page's CORS preflight
A sandboxed page is opaque-origin, so a fetch() of its own data file with
a non-safelisted header preflights with `Origin: null`. The auth hook
refused the OPTIONS (the html-view exemption admitted GET and HEAD only)
and, without a password, the security-headers hook answered a bare 204
with no Access-Control-Allow-Origin, so the fetch failed either way.

The exemption now admits OPTIONS for a live capability, and
registerSecurityHeaders answers it itself with Allow-Origin `*`,
Allow-Methods `GET, HEAD` and Allow-Headers `*` (no credentials are
involved). An unknown or revoked capability still gets the 401, and
writes on the capability path are still refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:12:45 +02:00
Codeman maintainer 4544379b3b style(auth): put hasValidWebviewCapability's JSDoc back above it
The html-view helper had been inserted between the web-tab function's
JSDoc and the function, so that doc block described the wrong function.
The helper now sits after hasValidWebviewCapability with its own doc.
No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:12:02 +02:00
Codeman maintainer fa23f8be2a feat(html-view): answer Range requests through the shared sendFileBody()
A rendered page's own <video> or <audio> could not seek, because the
route streamed a plain 200 without Accept-Ranges. sendFileBody() and
localFileSource() are now exported from file-routes.ts and the serving
route uses them, so it advertises Accept-Ranges and answers a Range with
206 + Content-Range (or 416), exactly like file-raw, the attachment raw
route and /api/download. The body sender hijacks the reply and copies
every header set before it (the sandbox CSP, CORS, no-store), which the
new test pins on a 206.

Media types stay in ASSET_TYPES: with Range support they work, and they
are served under the same sandbox CSP as everything else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:11:50 +02:00
Codeman maintainer bc940a3953 fix(html-view): require the resolved file of a mint to be HTML
The mint checked the extension of the requested path, then built the page
URL from the symlink-resolved file name. A `report.html -> notes.txt` link
minted a page URL for the text file. The resolved name now has to be
.html or .htm as well, the mint-side counterpart of the serving route's
resolved-path extension check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:10:39 +02:00
Codeman maintainer 432faf7254 refactor(html-view): validate the mint body with a Zod schema
POST /api/sessions/:id/html-view read `path` off an unchecked cast. It now
goes through HtmlViewOpenSchema in schemas.ts (a non-empty string of at
most 4096 characters without NUL or line breaks, no other keys) via
parseBody, like the other file routes, and keeps its "Missing path
parameter" message for an absent path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:10:24 +02:00
Codeman maintainer 69aa5724e7 fix(html-view): scope the hidden-directory rule below the workspace root
The mint refused an HTML file whose absolute path had any dot segment, so
a workspace that itself lives under a hidden folder (~/.local/src/app)
could never render one of its own pages. For a file inside the workspace
the rule now covers the path below the workspace root, which is all the
page's tree can reach; outside the workspace it still covers the whole
path. A hidden directory below the workspace root is still refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:09:28 +02:00
Codeman maintainer 876dbca074 fix(html-view): re-check workspace confinement on every served request
The serving route checked the blocklist per request but not
attachmentConfineToWorkspace, while the attachment serve path
(file-routes.ts) re-checks it every time. A capability minted for a
folder outside the workspace before an admin turned confinement on kept
serving that folder for the rest of its TTL.

The capability record now says whether its folder was admitted from
outside the workspace, and the serving route refuses such a record while
confinement is on. The flag is part of the reuse key, so an
out-of-workspace mint never picks up a capability recorded as
in-workspace for the same folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:09:00 +02:00
Codeman maintainer fe50ad0be3 fix(html-view): apply the dot and extension rules to the resolved path
The serving route checked hidden segments and the asset allowlist on the
requested name only; after realpath it checked containment and the
blocklist. A symlink `cfg.json -> .git/config` inside the page's folder
was therefore served as 200 application/json. The dot rule now also runs
on the segments of relative(rootDir, resolvedPath), and the extension
allowlist on the resolved file name, which also types the response.

Both realpath calls on the request path (the mint of an out-of-workspace
file and the serving route) are now `await fs.realpath` instead of
realpathSync, so a slow mount stalls one request, not the event loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:08:13 +02:00
Codeman maintainer ded7911d1e fix(html-view): refuse /tmp, /var/tmp and directories above a broad root
os.tmpdir() returns $TMPDIR when it is set, and macOS sets it in every
terminal session, so with TMPDIR set /tmp, /private/tmp and /var/tmp were
never treated as broad and /tmp/report.html got a capability over all of
/tmp. The broad-root list now names /tmp and /var/tmp literally (through
canonical(), so macOS gets /private/tmp), refuses any directory that is an
ancestor of a broad root (/home, /Users, /private), and treats a drive's
mount point (/mnt/d on WSL, /media/x, /Volumes/x) as broad as well.

The new test sets TMPDIR elsewhere and still gets /tmp refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:07:36 +02:00
Codeman maintainer 2797098aa5 Merge pull request #547: feat(file-preview): render local HTML files as sandboxed pages
Conflicts resolved:
- CLAUDE.md: kept master's Prompt uploads paragraph and its longer
  File-path links paragraph (XLSX admission limits), and inserted the
  PR's html-view sentence after the download-only rule.
- src/web/public/terminal-ui.js: kept master's tile-aware
  getSessionId() in the shared file-path link provider and added the
  PR's .html routing to the file preview on top of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 17:03:00 +02:00
Zoltan Zombory 4ecf7fa228 fix(terminal): strip OSC 52 from replays and copy each clipboard write once
xterm parses write() data after chunkedTerminalWrite has already cleared
_isLoadingBuffer, so the main terminal's replay gate never held: a tab switch
or reconnect re-copied the newest OSC 52 in the replayed history. Strip every
OSC 52 from the replay beside the DEC 2026 markers (OSC52_STRIP_RE). Tiles
keep their own _bufferLoading gate, which holds there.

One Claude Code copy arrives up to three times (its own OSC 52, tmux
load-buffer -w, set-clipboard forwarding), so identical text within
OSC52_DEDUPE_MS is dropped. The manual-copy fallback opens only in the
focused tab, so a background tab on the same session stays quiet.

The replay tests now drive the real chunkedTerminalWrite into
@xterm/headless and fail without the strip. Test harnesses that supply
DEC_SYNC_STRIP_RE by hand get OSC52_STRIP_RE too, and CLAUDE.md records
the rules next to Auto Copy.
2026-10-13 13:54:15 +02:00
Codeman maintainer c041a418e0 feat(mcp): codeman mcp, a Model Context Protocol server for any MCP client
Agents pick tools from MCP clients and registries, and Codeman had no MCP
surface: an assistant in Claude Desktop, Cursor, Codex or Gemini CLI could
not start or drive Codeman sessions without hand-written HTTP calls.

`codeman mcp` is a stdio MCP server and a CLIENT of the existing HTTP API
(it reuses codeman agent's transport and guards; no new route). Seven
tools: list_sessions, start_session, send_prompt, wait_for_session,
read_output, interrupt_session, delete_session.

- Dual-era: answers the legacy initialize handshake (2024-11-05 to
  2025-11-25) and the 2026-07-28 revision (per-request _meta,
  server/discover, resultType, and the ttlMs/cacheScope hints a modern
  tools/list must carry: Claude Code 2.1.296 drops every tool without
  them).
- Never guesses a server: the URL comes from --url or CODEMAN_API_URL.
  Unconfigured, it still lists its tools and every call says how to
  configure it.
- Prompting, interrupting and deleting are limited to sessions the process
  started (or, inside a Codeman pane, that session's own workers) unless
  --allow-any-session; never its own session. Control bytes are refused
  before any request.
- Waits default to 45 s (Codex's MCP tool timeout is 60 s), max 600 s.
- read_output returns text only: a client that prefers structuredContent
  otherwise showed the model metadata instead of the output.
- config/mcp-registry/server.json and package.json mcpName prepare the
  official MCP registry listing; sync-plugin.mjs keeps its version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:23:56 +02:00
Codeman maintainer 59b92f2991 fix(spreadsheet-preview): open openpyxl workbooks that have a comment, table or chart
The XLSX preview hands an admitted copy of the workbook to ExcelJS 4.4.0,
which links each worksheet to its comments, comment notes (VML), tables and
drawing by looking the relationship Target up verbatim in maps keyed by its
own spelling, then dereferences the result unchecked. openpyxl, the library
most agents write .xlsx files with, uses absolute Targets
(/xl/tables/table1.xml), its own comment part name (xl/comments/comment1.xml,
which ExcelJS never loads) and a drawing XML ExcelJS parses to nothing. One
comment, table or chart was enough for "Spreadsheet preview failed".

repairExcelJsModel() (spreadsheet-xlsx-core.js) runs on ExcelJS's parsed
model right before its reconcile: it re-points a link to the key ExcelJS
stored for the same part (so an openpyxl table is kept) and drops a link,
tablePart, drawing or hyperlink reference that would dereference nothing.
It adds nothing and resolves keys as own properties only. The worker wraps
workbook.xlsx.reconcile per instance to call it, and throws if a future
ExcelJS renames that method. SPREADSHEET_ASSET_VERSION is refreshed.

Tests: unit tests on model objects, and three real openpyxl 3.1.5
workbooks in test/fixtures/xlsx that fail the worker tests without the hook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:37:36 +02:00
timkjrandClaude Opus 5.5 683a1c218f fix(mobile): send a shell Paste image to the session the dialog was opened for
The Paste dialog's image branch called _uploadAndInsertImages() without a
session, so it fell back to the active session at pick time: after a tab
switch with the dialog open, the image went to the other tab, while the
text Send already used the session captured at open.

Also corrects the comments around the shared bracketed write: the size
refusal now lives in _sendBracketedPaste(), the budget covers the shell
Paste too, and a shell without bracketed paste (dash, bash before 5.1)
gets the markers stripped by tmux, so every line but the last still runs
there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:47:14 -05:00
Zoltan Zombory 435a56be10 feat(terminal): copy OSC 52 clipboard writes from the PTY into the browser clipboard
Programs inside the terminal (Claude Code /copy, tmux yanks with
set-clipboard on, vim) set the clipboard with OSC 52. xterm.js registers no
handler for 52, so on a headless host (no xclip, no DISPLAY) those copies
were silently dropped and never reached the remote browser.

Register one handler on the main terminal and on every TerminalTile (split
Pane B, tile grid). It decodes the base64 payload as UTF-8 and copies it
through _copyText, falling back to the manual-copy dialog when the browser
blocks the write. Read queries (`?`) are refused, and nothing is copied
during a buffer replay, so a tab switch or reconnect never re-copies old
OSC 52 sequences.
2026-10-12 20:39:07 +02:00
RandalixandClaude Sonnet 5.5 5b8c73827e fix(preview): list a remote folder under an explicit sh
sshd runs the command in the remote user's login shell. On Hufflepuff cd is
an alias that echoes the directory, so the listing script captured the folder
path twice and zsh aborted on the glob: every folder grid over ssh failed
with "remote host unreachable: no matches found". Wrap the script in sh -c,
as the transcript read already does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:06:19 +02:00
RandalixandClaude Sonnet 5.5 3c08971ff8 fix(preview): refuse ~/ in a Docker case; document the folder-route gate
A Docker case is a local session for the file routes, but `~` inside the
container is the container's own seeded home. Expanding `~/x.png` on the host
opened a different file than the one the agent printed. The attachment POST
and POST …/folders now answer 400 for `~/` when the session has `docker` set
(absolute paths are unaffected: the workspace is a bind mount).

Docs: the resolved-target gate, the route-parameter limit, the listing join
rule and the Docker `~/` refusal in architecture-invariants.md,
security-architecture.md and CLAUDE.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:07:39 +02:00
RandalixandClaude Sonnet 5.5 6bfc1b88d8 fix(preview): never glue the entries of a path listing into one path
`git ls-files`, `find` and `ls -R` print one path per row. An extensionless
entry followed by an equal-or-shorter one looked like a path broken short of
the edge, so `src/components` + `src/index.ts` linked `src/componentssrc/index.ts`
and the real link was gone. Measured with the shipped terminalPathLine over this
repo: `git ls-files` 8 false joins (one over 6 rows), `find src docs scripts` 5.

A next row is now a new entry when it opens with `./` or `../`, or has a `/` and
opens on the same top directory the broken token did. A wrapped path continues
inside a name, so the captures/2 + 026-10-10/... case still joins. After the
change: 1 and 0 (`docker/server.Dockerfile` + `docs/SPEEDRUN.md`, a broken token
with no extension that no row-shape rule can tell from a wrap).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:05:56 +02:00
RandalixandClaude Sonnet 5.5 6bffc0481c fix(preview): hold a folder file's resolved target to the attachment gate; accept long names
Folder routes only looked at the requested name, so `shot.png -> ../secret.bin`
and `notes.txt -> ../history` were served (registration answers 400 for both),
and a FIFO or device behind a supported name parked a worker on an open that
never returned. After resolving, the target's extension must be supported and
it must be a regular file (locally fs.stat, remotely the probe kind).

A 101..255-character file name answered 404 "Route not found": Fastify caps a
path parameter at 100 characters. The server now sets routerOptions.maxParamLength
(MAX_ROUTE_PARAM_LENGTH, shared with the route test harness) and a wiring guard
pins it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:04:34 +02:00
DDT f5e59283f9 fix(approvals): treat DeepSeek permission prompts as terminal-only alerts
Supervised-hook CLIs (DeepSeek Harness) raise Approvals Inbox items whose
answer route refuses keystrokes, but push notifications and the mobile/desktop
UI still offered Approve/Deny. Mark those items answerInTerminal, omit
approvalId (and therefore push actions) for supervised hooks, and show
"Answer in the terminal" / Open session instead of broken buttons.

Fixes #598 (functional part; docs gaps left for a follow-up).
2026-10-12 16:16:16 +08:00
timkjrandClaude Opus 5.5 8d92e577f8 fix(remote): accept the history marker glued to a row on tmux before 3.2
tmux before 3.2 prints no newline after `capture-pane -p` output, and
`-J` leaves the history's last row unterminated when the screen starts
inside a wrapped line. The marker row printed next is then glued onto
that row (`...first half<marker>\n31\n...`). The parser took the marker
only as a whole row, so it returned null and every load fell back to
the local capture while the screen stayed that way. Debian 11, Ubuntu
20.04 and RHEL 8 ship tmux 3.1c, 3.0a and 2.7.

The parser now takes the first `<marker>\n` wherever it ends a row (the
marker is random per read, so no row the pane holds ends with it) and
re-terminates a history that lacks its newline. A tail -c cut still
drops only the history's partial first row, now decided on the whole
output, so a cut inside the one row the marker is glued to leaves no
history instead of losing the marker. dropPartialFirstRow, no longer
called, is removed.

Docs and the findFrameInScreen JSDoc also say the splice does not place
the frame of an agent TUI mid-turn (a live region pinned to the bottom,
new rows inserted above it), which falls back to the history-over-frame
join.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 18:54:20 -05:00
JD ecb2e3d8f3 fix(telemetry): stamp a compaction on entering the null window
Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window.

Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands.
2026-10-11 12:36:59 -04:00
timkjrandClaude Opus 5.5 8e2594a274 fix(remote): read the remote history and screen in one tmux command list
The remote script ran the history capture, the history size and the
screen capture as separate tmux runs, so the shell kept writing between
them and rows that scrolled in those milliseconds were in neither
capture. Measured end to end against a LAN host with output streaming:
1-2 rows lost per load at the history/screen join (the history ended at
row N, the screen started at N+2); on a local busy pane, every read.

The three reads are now one tmux command list (capture \; display \;
display \; capture). tmux runs a client's command list back to back
without reading pane output in between, so the screen begins where the
history ends: 0 gaps in 200 reads of a local busy pane, and no gaps or
duplicates in 8 loads against the LAN host. This rests on tmux's
command-queue behaviour, not a documented guarantee.

tmux output cannot carry NULs, so a random marker row per read
(display-message) separates the history from the size and the screen;
the parser takes it only as a whole row. The NULs around the tmux output
are printed whether or not tmux succeeds, so a missing session still
reads as the script having run (no host back-off), with its own log
text when tmux printed something unparseable. The remote tail -c bound
now covers the whole output, history first, and is the configured cap
plus 1 MiB for the marker, size and screen (remoteTailBytes), so a cut
lands in the history; the route trims to the cap itself. A cut landing
exactly on the marker row keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 07:53:30 -05:00
RandalixandClaude Opus 5.5 3857fba065 fix(preview): folder grid review — multi-user scope, bounded probing, smaller items
- Multi-user: a non-admin's folder must resolve inside their own userSpacePath
  or the session's workingDir (folderAllowedForUser, the path picker's rule),
  checked on the resolved folder before it is read and again on each served
  file after symlink resolution; 403 otherwise.
- listFolder probes the local folder with probePathKind({ pastCap: true })
  first (unknown -> 503 via describeUnknownPath, never 404), then async
  fs.realpath; resolveFolderFile uses probePathKind instead of fs.stat.
- The guard runs on the realpath before readdir.
- FOLDER_PATH_LINK_PATTERN ends a folder at ':' (quadratic `://` rescan on
  colon runs; PATH=/a:/b linked as one folder).
- Load counters drop stale preview / folder-grid responses; closing the grid
  invalidates a listing still in flight.
- validFolderFileName refuses a leading dot; the remote listing matches
  extensions in any case.
- Docs: folder grid in the z-index lists; multi-user rule and bounded probe in
  the invariants and security docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 14:03:20 +02:00
timkjrandClaude Opus 5.5 28b41efa58 fix(remote): keep the remote screen rows above the local frame in a remote history load
The remote history and the local frame are read a round trip apart, so
two seams dropped rows at the join: rows that scrolled off the screen
during the round trip, and the top of a remote screen taller than this
pane (a shared session sized by a taller client under window-size
latest). Both are rows of the remote screen above where the local frame
begins.

The remote script now also captures the screen, right after the
history, in the same round trip. joinScrollbackAndFrame() places the
local frame among the screen rows (findFrameInScreen(): text compare
with escape sequences and trailing blanks ignored, the screen's trailing
blank rows and last content row left out, at least three rows of
overlap with two of them non-blank, highest match wins) and keeps the
screen rows above it between the history and the frame. When the frame
cannot be placed, the join is history over frame as before. The read's
buffer slack grows to 1 MiB, since the screen is outside the tail -c
bound.

Left: the history and screen are two tmux commands, so a row that
scrolls between them (milliseconds) is lost, and a screen that repeats
a whole block can place the frame at the higher copy, losing the rows
between the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:32:04 -05:00
timkjrandClaude Opus 5.5 063e6fc076 fix(terminal): keep the byte-recording load for a shell in the alternate screen, report a lines cut only below the ceiling
Review follow-ups for loading a shell from tmux's rendered history.

tmux adds no history while a pane is in the alternate screen, and a
nested tmux client (remote SSH and Docker cases, or tmux run by hand)
holds its pane there, so a full capture of it is one screen. The
display-message query that reads #{history_size} now also reads
#{alternate_on} (PaneCaptureOptions.capturedAlternateOn), and a request
carrying `alt=recording` (only SHELL_LOAD_QUERY sends it) is answered
the old way on such a pane: the visible frame over the tailed byte
recording. Agents, scroll pulls and Load full history are unchanged.
alternate_on cannot tell a nested tmux from vim or less, so a local
shell with one of those open takes the same fallback while it is open.

A full capture without `lines=` was reported as a 'tail' cut whenever
the pane held more than the configured ceiling (possible on tmux before
3.7), offering a Load full history that could never return more. The
cut is now reported only for a client bound below the ceiling.

The response carries `historyLinesCut` when that bound left lines in
tmux, and the banner then says earlier output may be retained instead
of naming the bytes a later tail cut, which undercount what tmux holds.
A bounded grid tile's shell load sends its own `lines=` (via
SHELL_LOAD_BASE_QUERY). CLAUDE.md, docs/architecture-invariants.md and
docs/api-reference.md describe the shell load, the cut report and the
fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:29:19 -05:00
timkjrandClaude Opus 5.5 136f11549b fix(remote): bound the history read's slot wait in the queue, back off only on transport failures
Review follow-ups for the remote scrollback read.

The 1.5 s slot-wait bound was checked inside the task, so it only fired
once runWithRemoteSshLimit() had already handed over a slot, after
everything queued ahead of it. runWithRemoteSshLimit() now takes
`maxWaitMs`: a waiter that times out leaves the queue, rejects with
RemoteSshSlotTimeoutError, never runs and never touches `active`. The
history read uses it, and a slot timeout does not back the host off.

Only a transport failure backs the host off now (ssh exit 255, a
timeout, a spawn error, output that is not the script's), via
classifyRemoteAliveExit(). A remote command failing is about that
session only. A live run against a real host showed a missing session
comes back as exit 0 with empty markers, because the `| tail -c` pipe
takes tail's exit status, so markers that arrived without a size are
also per-session.

fetchRemoteScrollback() takes an optional runner as a test seam; tests
now cover the bounded wait, each back-off class, its expiry, and the
masked missing-session case. A source pin covers the `-S 0` capture
branch. The two seams that can drop rows at the join (rows that scroll
during the round trip, a taller shared client) are documented in
docs/remote-sessions.md, and the limiter's header and the remote-ssh
invariants now name the history read as an ssh consumer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:28:25 -05:00
Aamer Akhter dd7774f9ad test(mobile): give the touch-scrollback case real local history
"keeps terminal touch drag available for scrollback with the visible
textarea enabled" built a Codex session on a blank terminal. With Codex
in transcriptPageKeys that pane is hollow, so the drag is correctly
consumed by the page-key fallback and never reaches scrollLines; the
fixture no longer described local scrollback.

The case now writes 200 lines into xterm (awaiting the write), turns the
visible CJK textarea on explicitly, and asserts the scenario before the
drag: normal buffer, baseY > 0, viewport at the bottom, not hollow, wheel
forwarding off, cjk-input-visible set. A real touch drag back into history
must then call scrollLines with negative lines, move the viewport up, and
send nothing to the CLI. Hollow Codex paging stays covered by
terminal-touch-paging.browser.test.ts.
2026-10-10 22:59:54 -04:00
Aamer Akhter d6c87bbd27 fix(terminal): end the page-key debt lifetime once travel repays it
pageKeysForGesture kept `prepaid` set after the travel had fully repaid a
pre-paid page, so the one-second debt lifetime still applied: on a 38-row
pane, 19 lines of -1 at 32 ms left { pending: 0, prepaid: true } and a -1
300 ms later sent nothing. `prepaid` now clears as soon as the pending
travel reaches or crosses zero, so the leftover is plain travel and the
150 ms gap applies again. Touch contacts are unaffected (never split by
time). The mixed parity step's reversal grows from 20 to 25 rows because
the -5 rows left after repayment are now plain travel and cancel against it.
2026-10-10 22:52:15 -04:00
Aamer Akhter 3ea1e03aaf fix(terminal): one touch contact is one paging gesture; bound slow ticks and wobble
Review of #527 (Ark0N, 2026-10-11):

1. A slow touch drag paged a full screen per line. touchmove calls the
   fallback once per whole cell, so a slow drag's calls were further apart
   than the 150 ms gesture gap and each pre-paid a page (12 lines at 170 ms
   sent 12 PageUps). The touch paths now tag every call, touchmove and
   momentum frames alike, with a per-contact touchId (incremented on
   touchstart), and pageKeysForGesture never splits a contact by time; a
   new contact is a new gesture.
2. A pre-paid debt now survives up to PAGE_KEY_DEBT_MS (1 s) of quiet when
   the next event goes the same way, so slow small wheel ticks (40 px every
   250 ms, a Firefox 1-line notch) pay it back instead of each pre-paying.
   A reversal inside the gap (or inside one contact) drops the debt and
   accumulates without pre-paying, so a +/-3 px wobble no longer pages
   back and forth. A reversal after the gap still pages at once. Main pane
   and tiles share the helper, so both get it.
3. A wheel consumed by the dialog check now logs page-keys-dialog-open.
4. The Dashboard wiki names newer Codex beside OpenCode; the invariants
   paragraph describes the new gesture boundaries.

Tests: unit regressions through the real _maybePageCliTranscript, helper
cases, tile/main parity for ticks and wobble, and a Chromium test that
drives the real initTerminal touch listeners (12 PageUps before, 1 after).
2026-10-10 22:50:06 -04:00
Aamer AkhterandClaude Opus 5.5 afab34608a Merge origin/master (1.41.0) into pr/grouped-rail-placement
One conflict, in i18n.js SKIP_SELECTOR: master added '.tab-name' itself
(the same fix this branch made for zh-CN tab names), so master's line is
kept and this branch's duplicate comment dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:19:01 -04:00
Aamer AkhterandClaude Opus 5.5 eaf924a291 Merge origin/master (1.41.0) into pr/codex-hollow-paging
Conflicts resolved by keeping both sides:
- terminal-ui.js: master's wheelDeltaWholeLines/sgrWheelReports next to
  this branch's pageKeyRows/pageKeysForGesture.
- terminal-tile.js: master's tile SGR wheel forwarding and Shift+wheel
  local scroll (_shiftScrollPending) kept; tile paging stays on the
  shared pageKeysForGesture state (_pageKeyGesture) with its per-tile
  dialog check.
- architecture-invariants.md: this branch's hollow-paging paragraph,
  with master's tile SGR forwarding and Shift+wheel sentences replacing
  the old "do not forward SGR wheel" note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:18:02 -04:00
RandalixandClaude Opus 5.5 da7ffec734 docs(preview): path forms, folder grid and folder grants
CLAUDE.md key pattern and architecture-invariants (File-path links) get
the new path forms (`findFilePathLinks`, `~/` expanded server-side,
`terminalPathLine` join rules) and the folder grid (grant + bare-name
routes, own registry, z-order, captured keys); remote-sessions lists the
folder routes for a remote case; security-architecture states the trust
level of a folder grant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 21:57:40 +02:00
RandalixandClaude Opus 5.5 1010d80b4f feat(preview): folder grid — browse every previewable file of a folder
Folder paths in the terminal and the response viewer are now links
(rooted or ~/ without an extension, relative with a trailing slash); a
click opens a grid of the folder's supported files, newest first, with
subfolder chips and a parent button. A tile opens the file preview,
which pages through the folder with ‹ › / ←/→; the preview of any file
gets a "Show folder" button.

Server: POST /api/sessions/:id/folders grants + lists a folder (local
fs or one ssh round trip for a remote case) under the attachment guard;
GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one
file by bare name through resolveServableAttachmentPath, so browser
requests still never carry a path. Grants live in their own small
registry so a big folder cannot evict the session's attachments. The
attachment raw route's body is now the shared serveServableRaw helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 21:54:37 +02:00
RandalixandClaude Opus 5.5 7d46a9d336 feat(preview): open ~/, relative and line-broken file paths
Terminal and response-viewer links only knew rooted absolute paths. Agent
output is full of the other forms: Claude Code echoes an attached image as
`~/repos/x/garage.png`, scripts print `builds/captures/x.png`, and Claude
Code wraps tool output a few columns short of the edge, so a long path
arrives as `.../captures/2` + `026-10-10/x.png` and only its prefix linked.

- constants.js: RELATIVE_FILE_PATH_LINK_PATTERN + findFilePathLinks() (one
  entry point for both consumers; never inside an http(s) URL),
  resolveLinkedFilePath() (relative -> session workingDir, ~/ as is),
  terminalPathLine() (glues a path a program broke over lines; guarded so
  ordinary short lines are never joined).
- terminal-ui.js: provider uses them, no overlapping links, opens the
  resolved path. Touch selection keeps terminalLogicalLine.
- app.js: response viewer links the same forms; only its own links are
  resolved, a markdown document's rebased links stay as they were.
- Server: registerExternalAttachment accepts ~/ (local homedir(); remote
  probe expands $HOME on the host). Guards run on the resolved path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 21:54:37 +02:00
JD 3e75e7509f fix(uploads): bound the paste-image workspace probe (#516)
The paste-image route wrote under session.workingDir via ensureUploadDir
with no bounded probe, so an upload onto a stalled network mount held one
of libuv's few threadpool workers until the mount answered. Probe the
workspace with probePathKind past the stall cap before the first disk
touch and refuse an unknown mount through describeUnknownPath, the way
POST /api/sessions already does.
2026-10-10 15:03:32 -04:00
timkjrandClaude Opus 5.5 b620e42ffa fix(terminal): load a shell from tmux's rendered history, not the byte recording
A shell's automatic loads (tab select, dropped-output recovery, clear
terminal, a split/grid tile's load) replayed `session.terminalBuffer`,
the raw PTY recording, ahead of the visible frame. That recording holds
full-screen repaints (an ssh'd tmux, less, any TUI: cursor home, then the
rows joined by newlines, prompt on the last row) drawn for the pane
height of the moment. Replayed into a terminal with fewer rows, each
repaint's last newline scrolls and pushes its top row into scrollback,
so one line tmux holds once shows up dozens of times (26 copies in the
case that found this, after the pane went from 24 to 23 rows). Remote
SSH shell sessions hit it constantly: their inner tmux repaints often.

Shells now load a bounded window of tmux's rendered history instead
(`SHELL_LOAD_QUERY`: full=1&tail=1MiB&lines=10000), the same source the
scroll-to-top pull already used. `lines=10000` bounds the synchronous
capture-pane, measured at ~8 ms per 1000 lines (~0.1 s here vs ~0.8 s
for a 100k-line history). Because that bound is shallower than xterm's
scrollback, the server now reports a cut: the capture's existing
display-message query also reads `#{history_size}`
(`PaneCaptureOptions.capturedHistorySize`), and the route marks a full
capture `truncated` / 'tail' when tmux holds more than it read, so the
banner keeps offering Load full history and scroll-to-top keeps pulling
older lines. The byte cap still reports 'capped'. The banner no longer
names an amount it cannot know ("less than 1 KB more") for such a cut.

Agent sessions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:49:29 -05:00
timkjrandClaude Opus 5.5 b33d6da58a fix(remote): load a remote session's scrollback from the remote tmux
A remote SSH session's local pane runs `ssh -t … tmux new-session -A` (or
`attach`), so the local tmux only sees the remote tmux client's full-screen
repaints and keeps a fraction of the history: measured 48 of 279 lines while
output streamed, none after a reattach. The remote pane, where the shell
runs, keeps all of it.

A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a remote
session now reads the scrollback rows from the remote pane in one ssh round
trip (`#{history_size}` + `capture-pane -S -<N> -E -1`, exact `=name:`
target, newest bytes kept by a remote `tail -c`), and the local capture
reads only its visible frame (`-S 0`), spliced below them
(`PaneCaptureOptions.scrollbackOverride`). The local frame and caret are
kept because the live stream keeps drawing into the local pane.

Fail-soft and bounded: connection options from buildSshConnectionArgs(),
the shared remote-ssh limiter (a slot waited for longer than 1.5 s is given
up), a 5 s timeout, and a 30 s per-host back-off after any failure, which
keeps the local capture as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:49:09 -05:00
timkjrandClaude Opus 5.5 d157911166 fix(mobile): shell Paste inserts a multi-line block as one bracketed paste
The shell accessory bar's Paste overlay sent its text raw and then an
Enter (`app.sendInput(text)` + a delayed `\r`), so every newline in a
multi-line paste reached the shell as Enter and each line ran on its own
the moment it arrived, followed by one more Enter.

Send it the way a terminal paste does instead: one write wrapped in
ESC[200~ ... ESC[201~ with newlines as CR, through the same path Compose
already uses (its bracketed write is extracted into
`_sendBracketedPaste`, unchanged for Compose). Paste never presses Enter:
the block lands at the prompt for the user to review and run. The
overlay is only reached for shell sessions (the shell bar's Paste button,
and Compose redirecting a shell), so agent sessions are unaffected.

Also: a paste over the input-frame budget is refused with a toast and
keeps its text for trimming (the raw path could exceed the WS frame
limit), and the paste goes to the session the overlay was opened for,
not whichever tab is active at Send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:49:01 -05:00
Aamer Akhter cde90a465b feat(tabs): pin tabs to a Focus section on the vertical rail
A session or web-tab ref in the owner tab layout may now carry
`focus: true`. "Add to Focus" in a row's menu (right-click on a web tab)
sets it; the vertical rail then draws a shortcut to that tab in a pinned,
collapsible "Focus" section at its top, while the row itself stays where
it is. A click on a shortcut selects its tab, a right-click or the menu
key offers "Remove from Focus".

Model: `TabRef.focus?: true`, parsed strictly (only `true` or absent) and
carried through normalization, every pure edit and the legacy-order
projection; `setTabFocus()` is the pure helper. TabLayoutService needed
no change and no route was added: the flag rides the existing
`PUT /api/tab-layout`. Browser: `copyRef`/`normalizeLayout` keep `focus`
the way they keep `placement` (the browser writes whole layouts, so a
dropped field would be erased on every save), `setFocus` is a named
operation through `createEditCoordinator` and replays on a 409, and
`focusRefs()` (also on `project()`) lists what the rail can draw.

The section is its own element in #tabRail above #sessionTabs, so it
works on the flat rail (no named groups) as well as the grouped one, and
shortcuts are never rows: no Alt+N badge, no tree item, no lineage
anchor, no drag target. It is a list with one tab stop and
Up/Down/Home/End between shortcuts. Collapse is per-device localStorage
under its own key. A shortcut whose session closed or whose web tab is
not open drops out; the server already prunes deleted refs. zh-CN
strings added.
2026-10-10 13:43:20 -04:00
JD 34c903da41 feat(mobile): phone Attach button and camera-roll video uploads
A phone-only toolbar Attach button hands the agent a photo or a video with the
keyboard down. It owns its own file input, positioned over the button so iOS
anchors the picker popover to it; the picked paths land on the prompt as unsent
text, so Compose adopts them together with anything already typed and Use
terminal keyboard hands a single-line draft back the other way.

Videos take a second path on the same route: ?kind=video streams the file to
disk under MAX_PASTE_VIDEO_BYTES (1GB, env CODEMAN_MAX_PASTE_VIDEO_BYTES)
instead of buffering it, sniffs the container from the first bytes as they
arrive, drains a mislabeled body unwritten and unlinks on any failure. It draws
on its own 3/min per-(IP, session) rate bucket beside the image path's 30/min:
at 1GB each, 30/min would be 30GB of disk a minute. Without the flag the route
is byte-identical to the image-only version, same 50MB cap and same bucket.

One client classifier, _promptAttachKind (MIME first, then the name's
extension), drives every pick and paste surface: the Attach button, terminal
drop, the tile grid's drop, the Compose and paste-dialog pickers and the
clipboard handlers. A pick that lands while the tile grid owns the terminal
goes to the PTY rather than the parked main-terminal overlay.
2026-10-10 13:40:04 -04:00
JD 551a7f2792 feat(telemetry): read out Claude Code's prompt cache state per session
Claude Code's statusline has carried a prompt_cache object since v2.1.251, and Codeman's statusline exporter already received it, but StatusTelemetrySchema stripped the whole object, so Codeman never knew whether a session's conversation was still cached. Keep it and show it.

StatusTelemetrySchema now lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer), while parsePromptCache normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. Session.promptCache rides toState with a change-only session:promptCache event, and the route broadcasts the stored object.

The Session Options (Respawn tab) readout shows warm-until or cold with the re-read stake, the cache hit rate, the last miss's causes, and, for Claude Code's own idle compaction (recache_tokens_if_cold null while expected_rebuilds rose), a "compacted at HH:MM" note that is never raised as an alert. setPromptCache stamps that on a new object rather than mutating the parsed report, so the stamp reaches the broadcast. Sessions without a statusline of their own also get a cache:until HH:MM / cache:cold group in the terminal footer. The last report is restored on a Codeman restart (like displayModel) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose expiry has passed reads as cold, not stale-warm. A launch without the exporter records why (collection off, workspace statusline, remote) so the row says the cause instead of "not reported yet" forever.

This is the sensor a later opt-in keep-warm would read, and the TTL it reports is the source of truth rather than the plan tier.
2026-10-10 13:09:32 -04:00
DDT 331841f482 fix(mcp-sync): read Gemini bare url as http, OpenCode V2 mcp.servers, and opencode.jsonc
Gemini v0.63.0 treats a bare url as streamable HTTP first, OpenCode V2 nests
servers under mcp.servers with disabled instead of enabled, and a config kept only
as opencode.jsonc was read as "no OpenCode config". Fix all three read gaps;
the write path is unchanged. Fixes #586.
2026-10-11 00:28:44 +08:00
Devvyn 0fced6ea97 feat(session-options): show the session's working folder with a copy button 2026-10-10 13:50:05 +08:00
Devvyn 79fd2dc8e4 fix(sessions): restore a session's colour, pin and image watcher after a restart
restoreMuxSessions() re-applied name, auto-clear, auto-compact, auto-resume, nice and the
flicker filter from state.json but not color, pinned/pinnedAt or imageWatcherEnabled, which
the reboot-restore path (reapplyPersistedSessionState) already restores. After a restart or
crash a surviving pane came back uncoloured, unpinned and with its image watcher off, and the
next persist wrote that over the saved values. Found by killing -9 a server with a coloured,
pinned shell session and restarting it.
2026-10-10 13:50:05 +08:00
Devvyn 0ae679553b fix(cases): show the real reason when Link Existing rejects a case
POST /api/cases/link passed a fixed 'Invalid request body' to parseBody, which replaced the
schema's own message, so a bad name and a bad folder path (a leading ~, a character a session
working directory cannot have) looked the same. Drop the override so the first validation
issue is returned.
2026-10-10 13:50:04 +08:00
Codeman maintainer 2c55fe907e feat(tiles): move tiles by hand, and detach a tile into its own window
Gesture control now moves tiles: with the tile grid open, pinch anywhere
on a tile and carry it onto another tile (they swap) or an empty cell (it
moves there); a session tab carried onto the grid joins it there. The
overlay asks tile-grid.js what is under the hand (tileAtPoint,
tileDropTargetAt) and drops through the mouse drag's own paths
(dropSessionOnTile / dropSessionOnSlot, so _reorderTiles stays the one
move path).

Detach Tiles (App Settings > Appearance, per device, OFF by default,
desktop only):
- a tile's header let go outside the browser window opens the session as
  a pop-out (detachSession, which now takes an optional size and screen
  position), the tile's size, near the drop point, after a 120 ms settle;
- dropped on another Codeman window's tiles it moves there: that window
  docks it through the target's own drop and posts 'tile-adopted' on the
  window channel, and only the window it was dragged from lets go of its
  copy (a last tile goes to the welcome screen, never onto that session);
- a pop-out's title drags back onto any grid: the session is taken back,
  the pop-out is asked to close and is released (stops answering
  roll-calls; one a script cannot close says where the session went), and
  its stale 'detached' answers are ignored for 2 s;
- a tile's menu offers "Open in a new window", and a hand that lets a tile
  go outside the grid pops it out too.

With the setting off nothing changes. No server change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:17:26 +02:00
Codeman maintainer df1a493b8c perf(terminal): tab switch to a pane with no scrollback loads only its screen
After a session's first load per page, a tab switch fetched a 1 MiB tail of
the server's byte stream. For a fullscreen claude pane (alternate screen,
tmux `history_size` 0) that tail is old repaints of one frame, and an idle
tab parsed it twice (the cached copy, then the fresh one).

Every terminal response now records `paneHistoryLines` per session, and
`selectSession` takes `full=1` whenever the last report was 0. For such a
pane that capture is the visible frame, a few KB, which is also what a page
load already shows. A response without the field forgets the entry, so
unknown never counts as empty; a pane that starts keeping history goes back
to the tail on the next switch. Shells and the refresh path are unchanged.

Measured with real prod payloads through the real client in chromium:
median switch 1.26-1.66 s -> 0.10-0.19 s, parse ~1.1-1.5 s -> 2-3 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:25:00 +02:00
Codeman maintainer e2453fe97b fix(terminal): partial-history notice only when there is history to load
The "Showing the most recent 1.0 MB of this session. 4.8 MB more may still
be retained." bar covered the top of the terminal on nearly every tab switch,
and for fullscreen claude panes it could never be acted on: `truncated`
measures the server's byte stream, while Load full history asks tmux, which
holds no scrollback for a pane that lives in the alternate screen.

- The capture reads `#{history_size}` in the cursor query it already makes
  and the terminal route returns it as `paneHistoryLines`.
- A reported 0 hides the notice; a positive count replaces the byte gap in
  the message.
- The notice is lazy: it appears once a wheel/touch gesture reaches the top
  of the browser buffer (after the pull that gesture starts), leaves when
  the user scrolls back to live output or switches tabs, and its dismissal
  sticks per session until reload.
- formatHistoryBytes no longer prints "1024 KB" just under 1 MiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:56:37 +02:00
Aamer Akhter 5fb56b0889 fix(tabs): put child follow behind a setting, stop unpinning children on order PUTs
Review fixes for "place new, child and web tabs into groups".

- Spawned tabs follow their parent only with the new synced setting
  spawnedTabsFollowParent (App Settings > Appearance > Tabs, off by default).
  TabLayoutService.sessionCreated() reads it fresh through a
  childrenFollowParent dep: off, a spawned session lands after its parent and
  is marked manual in the same write; on, it follows until moved by hand. The
  row menu shows the placement note and "Follow parent again" only when on.
  "New session" in a group menu is unaffected. zh-CN label and a
  Settings-Reference row.
- applyLegacySessionRank marks a child manual only when the requested order
  splits it from its parent's block. A PUT of the current order, or one that
  moves a family together, leaves every stored placement alone.
- The browser never echoes a create or delete through PUT /api/session-order,
  with or without groups: the layout service already re-projects the order and
  broadcasts it, and syncSessionOrder adopts it.
- The placement note in the row menu is plain text described by the menu, not
  a focusable menu item.
- A tab that first appears inside a collapsed group briefly marks that group's
  header.
- i18n skips .tab-name, so tab names are never translated.
- Tests: legacy-order cases for an unchanged order, a family reorder and a
  split; service cases for the setting and for order PUTs; the scheduled
  registration test now exercises register/undo; the grouped-create test binds
  port 0; one route case runs through the real layout service.
2026-10-09 20:34:37 -04:00
Aamer Akhter d7198dd058 fix(terminal): share the gesture-aware page-key math with tiles, add opencode
Review follow-up after the rebase onto 1.40.0, where tiles began paging a
hollow buffer through the shared CodemanTerminalInput helpers and opencode
joined the hollow check (#555).

- opencode declares transcriptPageKeys in stock.ts, so the paging list read
  by both panes is claude, codex and opencode. Without it, reading the
  capability instead of the old id check would have turned opencode paging
  off again. The stock-set and render tests name it.
- The gesture rules (first trackpad-sized event pages at once and is owed
  back, wheel notches accumulate, a pre-paid debt dies with its gesture,
  pinch and mostly horizontal swipes are consumed without paging) move out
  of the primary pane into the pure CodemanTerminalInput.pageKeysForGesture.
  The primary pane and TerminalTile both call it with their own state, so a
  Codex or opencode tile pages exactly like the main pane.
- The dialog check stays per pane: the primary pane reads tabAlerts for the
  active session, a tile for its own sessionId, so a wheel over a tile never
  pages into that session's permission prompt while the focused tab is clear,
  and a tile is not blocked by a dialog in another tab.

Tests: pure helper cases in terminal-scroll-routing, tile cases for Codex
paging, first-event paging, notch accumulation, pinch and swipe, byte parity
with the primary pane over a mixed sequence, and the per-session dialog
check in both directions.
2026-10-09 20:27:16 -04:00
Aamer Akhter a4926e49f1 fix(terminal): keep the page-key fallback out of swipes, pinches, wheel notches and dialogs
Review follow-up for the hollow-pane PageUp/PageDown fallback
(_maybePageCliTranscript):

- A mostly horizontal wheel swipe (both deltas present, |deltaX| > |deltaY|)
  is consumed without paging. The touch path already locks its axis.
- A pinch (wheel events with ctrlKey, as Chrome reports one) is consumed
  without paging.
- Only a trackpad-sized opening event (under 2 rows) pages at once. A wheel
  notch (100 px, 4 rows) accumulates toward half a screen as before, so five
  slow notches send one PageUp again instead of five. A debt from a pre-paid
  page still dies with its gesture; plain wheel travel carries across notches.
- Nothing pages while the active tab's alert is 'action' (a pending
  permission_prompt or elicitation_dialog), so a page key never reaches an
  open dialog's selector.

Docs: the invariants paragraph now says which Codex versions were seen
drawing on the alt screen (0.157.1, 0.160) and that 0.147.0 still draws
inline, without claiming a cutoff.
2026-10-09 20:21:05 -04:00
Saqeb Akhter 2eff6dcbdd fix(terminal): page a hollow Codex transcript with PageUp/PageDown from the first wheel event
Two changes to the last-resort page-key scroll (_maybePageCliTranscript), the
path a wheel or touch gesture takes when it is not forwarded to the CLI and
the local xterm buffer has no history (normal buffer, baseY 0).

Codex now takes that path too. Newer codex draws on tmux's alternate screen,
which the full strip hides from xterm, so a codex pane is as hollow as a
repaint-mode Claude pane and the wheel did nothing. Codex still ignores SGR
wheel reports and is still never forwarded the wheel; this sends plain
PageUp/PageDown keys, which codex does page its transcript on (measured on
0.157.1 locally and 0.160 over a remote session). Which modes may page is a
new registry capability, capabilities.transcriptPageKeys (claude and codex in
stock.ts), published to the page as window.__codemanTranscriptPageKeys, so
terminal-ui.js holds no CLI id for it.

The first event of a gesture now pages at once. The fallback used to send a
key only after half a screen of accumulated travel (19 rows, about 475px on a
38-row pane), so an ordinary trackpad flick sent nothing. The skipped travel is
owed back by the rest of the gesture, keeping one page per half screen; a pause
of more than 150 ms, a direction change or a tab switch starts a new gesture.
2026-10-09 20:19:46 -04:00
Aamer Akhter b8ccfc33d1 feat(tabs): place new, child and web tabs into groups
New sessions, spawned child sessions and saved web tabs now land in tab
groups instead of always starting in Ungrouped.

New session in a group:
- The group menu gains "New session" (first item). It expands a collapsed
  group and runs ONE launch with the current case and run mode; a launch
  already in flight is reported, not queued.
- run({ count, tabGroupId }) threads the group through every launch path
  (local POST /api/sessions, remote/docker and external-CLI
  /api/quick-start). The count overrides the steppers for that launch only.
- Server: POST /api/sessions and /api/quick-start accept an optional
  tabGroupId (Zod, 1-100 chars; null/empty is a 400). The routes pass it to
  addSession(), and TabLayoutService.sessionCreated() places the session at
  the end of that group in the SAME versioned write that first records it.
  The session is added to the live map inside the owner lock (with a
  rollback when the write fails), so no queued layout edit can commit it
  first. The group is looked up in the creating owner's own layout, so an
  unknown, deleted or foreign id is ignored instead of failing the spawn. An
  explicit group is a hand placement: a child placed this way is manual.
- The create response carries the committed tabLayout (only when tabGroupId
  was sent), adopted before the new tab is drawn. Responses without the
  field are unchanged.

Child sessions (parentSessionId / X-Codeman-Parent-Session):
- A child nobody placed follows its parent: the server's existing
  normalization already puts it after the parent's rows; the browser now
  draws a not-yet-stored child there too (project({ parents })) and, while
  groups exist, adopts the server's order instead of echoing its own append
  back through PUT /api/session-order (which pinned every child manual).
- The row menu says where a child sits (follows, placed by hand, parent
  closed, parent loop) and offers "Follow parent again" only to a hand-placed
  child. A child whose parent is gone stands on its own and is never
  re-adopted.

Saved web tabs:
- Web tabs are layout refs like sessions; openness stays per device. A
  grouped web tab shows (and takes an Alt+N slot) only while open here and
  reopens where the layout keeps it. Pinned by tests; no behaviour change.

Unchanged: the flat rail and header strip (markup, HTML5 drag, append and
save on create), the tree semantics and single tab stop, sessionOrder and
Alt+N, and every request that does not send tabGroupId.
2026-10-09 20:16:45 -04:00
stefaan van hoofandClaude Opus 5.5 91bdaee724 fix(html-view): address review — scope, source view, tests, docs
- Refuse to mint for an HTML file in a hidden directory or directly in a
  broad root (/, home, tmpdir, cases and user-space roots): the capability
  serves the directory tree recursively to the page's own scripts.
- Keep HTML's source view and editor: a Page pill (per-device htmlRendered,
  like MD) toggles to the file-content text path, and a refused mint (remote
  case, broad root, hidden dir) falls through to it automatically.
- Tests: html-view auth exemption edges, revocation on logout / admin logout /
  user deletion, out-of-workspace mint (blocked tree, confinement), hidden
  and broad roots.
- Docs: Working-With-Files, architecture-invariants, security-architecture,
  the file-routes comment and CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 09:21:55 +02:00
stefaan van hoof 56978f2cc9 Merge remote-tracking branch 'origin/master' into feat/html-file-view 2026-10-08 09:15:35 +02:00
stefaan van hoofandClaude Opus 5.5 fe77b5238e feat(file-preview): render local HTML files as sandboxed pages
Clicking an agent's `.html` path showed source text or downloaded it, so a
generated report could only be viewed by asking the agent to start a web
server. Render it instead, without serving HTML same-origin:

- POST /api/sessions/:id/html-view mints a capability for the file's own
  directory (workspace path, or outside it under the attachment guard).
- GET /html-view/:cap/* serves the page and its relative assets with
  `Content-Security-Policy: sandbox` WITHOUT allow-same-origin, so the page
  runs in an opaque origin (also as a top-level tab) and cannot read the
  Codeman document, its cookies or its API.
- Served files: non-dot, asset-allowlisted, realpath-confined to that
  directory, blocklist applied; GET only. Capabilities are memory-only with a
  rolling TTL and are revoked with the web-tab ones on logout.
- Frontend: the file preview renders HTML in a sandboxed iframe (popout opens
  the same URL); terminal `.html` links go to the preview, not the log viewer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:26:39 +02:00
173 changed files with 19059 additions and 639 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.41.0",
"version": "1.41.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+51
View File
@@ -1,5 +1,56 @@
# aicodeman
## 1.41.1
### Patch Changes
- ### Thanks
- @timkjr for four PRs: shells that reload from tmux's rendered history instead of replaying old full-screen repaints (#601), the full scrollback of a remote SSH session read from the remote tmux (#602) with no rows lost at the join (#609), and a phone shell Paste that inserts a multi-line block instead of running it line by line (#600).
- @aakhter for three PRs: Codex panes that page their transcript with the wheel and touch (#527, fixes #508), new and spawned tabs that land in tab groups (#528), and a Focus section of shortcuts at the top of the vertical rail (#582).
- @JDProfresh for three PRs: Claude Code's prompt-cache state per session (#607, step 1 of #554), the phone Attach button with camera-roll video uploads (#606, part two of #553), and an upload that no longer stalls the server on a dead network mount (#605).
- @Randalix for clickable `~/`, relative and hard-wrapped file paths plus a folder grid for browsing a whole folder of renders (#604, closes #603).
- @opticon454 for three PRs: a Working folder row with a Copy button in Session Options (#593), tab colour, pin and image watcher surviving a restart (#591), and Link Existing saying why it refused a case (#590).
- @ideaCompany, a first-time contributor, for rendering local HTML files as real pages in the File Viewer (#547), and for the careful security write-up behind it.
- @zomby1, a first-time contributor, for OSC 52 clipboard support (#611).
- @Hugo-DDT, a first-time contributor, for two PRs: MCP server sync reading Gemini's bare `url`, OpenCode's V2 layout and `opencode.jsonc` (#599, fixes #586), and DeepSeek permission prompts that no longer offer Approve/Deny buttons that cannot work (#610, from #598).
**HTML files open as pages, and markdown opens rendered from the terminal (#547).** Click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer now shows it as a real page, with its own scripts, styles, images and data files from its folder, instead of raw source. A **Page** pill switches to the source and its Edit button. The page runs in a sandbox with an opaque origin: it cannot read Codeman's cookies, use Codeman's API or read files outside its own folder. Network access is open, so a dashboard that loads Chart.js or fonts from a CDN works as it would opened from disk. Broad folders (`/`, home, `/tmp`, a drive's mount point) are refused, so put a report in a folder of its own. And a `.md` or `.markdown` path clicked in the terminal now opens the rendered markdown viewer everywhere (main terminal, tiles, split pane, touch), instead of the live log viewer that showed "streaming"; the MD pill still gives the source. Logs, `.txt`, `.json` and code keep the live log viewer.
**`codeman mcp`: drive Codeman from any MCP client (#615).** A stdio Model Context Protocol server with seven tools (list, start, send, wait, read, interrupt, delete), so Claude Code, Codex, Gemini CLI, Cursor or Claude Desktop can start worker sessions in any installed agent CLI, prompt them, wait for them and read what they did. It is a client of the existing HTTP API with the same guards as `codeman agent`: it refuses control characters in input, never acts on the session it runs in, and by default only touches sessions it started. Setup lines for Claude Code, JSON clients and Codex are in the README and the wiki page "Driving Codeman From An Agent".
**The codeman skill for every agent CLI (#613).** With **Codeman Agent Skill** on (App Settings > Agents & CLIs > All agent CLIs, still off by default), Codex, Gemini CLI, OpenCode, Antigravity, Grok Build, DeepSeek Harness and OMP sessions now get the same `codeman` skill Claude Code had, in `.agents/skills/codeman`, so an agent in any mode knows how to start and manage worker sessions. `codeman skill install --mode <cli>` installs it by hand.
**Tiles: move them by hand, or tear one off into its own window (#584).** With the camera gesture overlay on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it. With the new **Detach Tiles** setting (App Settings > Appearance, off by default), drag a tile's header out of the window and it pops out into its own window; drop it on another Codeman window's tiles and it moves there. Known limits: all Codeman windows in one browser share one saved tile layout, a drag between two browser profiles is not seen by the source window, and a drag-out held for more than a few seconds can be blocked as a pop-up.
**Focus section on the vertical rail (#582).** "Add to Focus" in a row's menu (or right-click a web tab) pins a shortcut to that tab in a small collapsible section at the top of the rail. The tab itself stays where it is.
**Tabs land in groups (#528).** A tab group's menu has **New session**, which starts a session straight into that group. A session an agent spawns lands right after its parent; App Settings > Appearance > Tabs > **Spawned tabs follow their parent** (off by default) keeps it moving with the parent.
**Folder grid and more clickable paths (#604).** Paths written as `~/...`, relative to the session folder, or broken across terminal lines are now clickable in the terminal and the Response Viewer, and folders are too: a folder opens a grid of its files (renders, screenshots, reports) that you can page through with the arrow keys.
**Phone Attach button and videos (#606).** On a phone the toolbar has an Attach button that opens the system file sheet directly, without raising the keyboard, and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into `.codeman-uploads/` like images.
**OSC 52 clipboard (#611).** A program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) now puts the text on your browser's clipboard, in the main terminal and in every tile. Read requests are refused, and a replayed buffer never copies.
**Prompt-cache readout (#607).** Claude sessions show whether their prompt cache is warm (`cache:until HH:MM`) or cold in the footer and in Session Options > Respawn, restored after a restart, with a reason when a launch will never report it. Display only, nothing changes behaviour.
**Faster tab switches to fullscreen Claude (#588, #583).** Switching back to a fullscreen Claude tab now loads only its current screen (a few KB) instead of replaying a 1 MB tail of old redraws (about 1.3 to 1.7 s saved per switch). The "Showing the most recent 1.0 MB" bar now appears only when there really is older history and you scroll to the top looking for it, gives a line count, and stays closed once you close it.
**Fixes.**
- **Shells reload cleanly (#601).** A shell tab, tile or split pane reloads from tmux's rendered history, so a prompt no longer appears four or five times and lines no longer go missing after a resize or a full-screen program.
- **Remote SSH sessions keep their scrollback (#602, #609).** Loading a remote session's full history reads it from the remote tmux in one bounded ssh round trip, where the local pane kept only a fraction (48 of 279 lines in one measurement, none after a reattach).
- **Codex pages with the wheel and touch (#527).** A Codex pane on its alternate screen pages its transcript with PageUp/PageDown from the first wheel notch or swipe, in the main terminal and in tiles, and never pages into an open permission dialog.
- **openpyxl spreadsheets preview (#612).** An `.xlsx` written by openpyxl with a cell comment, a table or a chart now opens in the File Viewer instead of failing.
- **Phone shell Paste (#600).** A multi-line paste in a shell is inserted as one bracketed paste and waits for Enter, instead of running each line as it arrives.
- **Restart keeps tab colour, pin and image watcher (#591).**
- **Session Options shows the working folder (#593)**, with a Copy button and a label that says when the folder is on a remote host or mounted into a Docker container.
- **Link Existing says why (#590)**, for example "Invalid case name format", instead of "Invalid request body".
- **Uploads on a dead mount (#605).** A prompt upload into a workspace on a network mount that stopped answering is refused at once instead of tying up a server thread.
- **MCP server sync (#599).** Gemini servers with a bare `url` sync as HTTP, OpenCode's V2 `mcp.servers` layout and `opencode.jsonc` are read.
- **DeepSeek approvals (#610).** DeepSeek Harness permission prompts show "Answer in the terminal" in the Approvals Inbox, the phone NEEDS YOU strip and push notifications, instead of Approve/Deny buttons the server always refused.
**Fixes applied while landing.** Remote SSH and Docker tabs keep their scrollback on a tab switch (they were about to lose it with #588). No colour bleed where remote history meets the live screen (#602, #609). A bare `~` in a Docker case no longer lists the host's home folder, and shell prompts (`user@host:~/proj$`) no longer turn into broken folder links (#604). Spawned sibling tabs keep their creation order (#528). An OSC 52 copy over plain HTTP gives keyboard focus back to the terminal (#611). A refused video upload answers instead of hanging, and the zh-CN "Attach" label of the tile grid is back (#606). The Working folder row fits on phones, with zh-CN text (#593). OpenCode sync no longer stalls on a server named `servers` (#599). Moving a tile to another window no longer overwrites that window's saved layout (#584). HTML pages: a `~/` link to an `.html` file renders as a page instead of falling back to source, `/tmp` is refused even when `TMPDIR` points elsewhere, symlinks cannot get around the hidden-file and file-type rules, workspace confinement is re-checked on every request, media in a page can seek, and the service worker never caches a page (#547). Codex users of `codeman mcp`: the config now forwards the session id, so its self-protection works (#615).
## 1.41.0
### Minor Changes
+15 -11
View File
File diff suppressed because one or more lines are too long
+50
View File
@@ -962,6 +962,56 @@ codeman agent rm "$SID" # any session except t
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
### MCP server (`codeman mcp`): drive Codeman from any MCP client
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged.
```bash
# Claude Code
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
```
```json
{
"mcpServers": {
"codeman": {
"command": "codeman",
"args": ["mcp"],
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
}
}
}
```
```toml
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
[mcp_servers.codeman]
command = "codeman"
args = ["mcp"]
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
tool_timeout_sec = 120
```
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `"CODEMAN_PASSWORD"` to `env` when the server has a password (or let it read the data dir's `.env`). Without the `codeman` command on PATH (a git checkout run without the installer), use `node /path/to/Codeman/dist/index.js mcp`.
| Tool | Does |
| ---- | ---- |
| `list_sessions` | Every session you can see, with its run mode, status, folder, and whether these tools may prompt it |
| `start_session` | A new worker in a case (a fresh scratch folder, or an existing case) in any installed CLI; waits until it can take a prompt |
| `send_prompt` | Types a one-line prompt and presses Enter; optionally waits for the end of the turn |
| `wait_for_session` | Waits for a signal (`stop`, `blocked`, `idle`, `working`, `exit`) or a literal marker in the output |
| `read_output` | The last answer, the whole conversation, or the last bytes of the terminal |
| `interrupt_session` | One Escape: stops the current turn, keeps the conversation |
| `delete_session` | Closes a worker (its case folder stays) |
Rules the server enforces rather than documents:
- **It never guesses a server.** The URL comes from `CODEMAN_API_URL` or `--url`; without one it still starts and lists its tools, and every call says how to configure it.
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions this server started (and, run inside a Codeman session, that session's own workers). Listing, reading and waiting work on everything. `codeman mcp --allow-any-session` lifts the limit, for a user who wants an assistant to drive their existing tabs. It never acts on the session it runs in.
- **Waits fit the client.** A wait defaults to 45 s (Codex cuts MCP tools off at 60 s by default) and is capped at 600 s; `timed_out` / `still_working` means call again.
- **Both protocol eras.** It answers the 2026-07-28 revision (per-request `_meta`, `server/discover`) and the `initialize` handshake of 2024-11-05 to 2025-11-25.
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.41.1",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
"source": "github"
},
"packages": [
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.41.1",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "mcp"
}
],
"environmentVariables": [
{
"name": "CODEMAN_API_URL",
"description": "Where your Codeman server runs, for example https://127.0.0.1:3000. Never guessed.",
"isRequired": true,
"format": "string"
},
{
"name": "CODEMAN_PASSWORD",
"description": "Codeman's HTTP Basic password, when the server has one.",
"isRequired": false,
"isSecret": true,
"format": "string"
}
]
}
]
}
+3
View File
@@ -23,6 +23,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/tab-rail-search.browser.test.ts',
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -35,6 +36,7 @@ export const BROWSER_TEST_GLOBS = [
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/terminal-tile-scroll.browser.test.ts',
'test/terminal-touch-paging.browser.test.ts',
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
@@ -47,6 +49,7 @@ export const BROWSER_TEST_GLOBS = [
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
'test/markdown-anchor-links.browser.test.ts',
'test/fullscreen-tab-switch-capture.browser.test.ts',
];
/**
+121 -5
View File
@@ -43,8 +43,9 @@ payload return `{ "success": true, "data": {} }`.
> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`,
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect),
> `GET /html-view/:cap/*` (a [rendered HTML file](#rendered-html-files) and its
> assets), and the `GET /ws/sessions/:id/terminal` WebSocket upgrade.
>
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
@@ -543,20 +544,31 @@ client that opens many concurrent waits against one session will still hit the c
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. The capture runs synchronous tmux calls on the server; the
`truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older
lines in tmux, so the remainder is more than `fullSize - retainedBytes`), and
`captureCols`/`captureRows` when the pane's geometry was read. `paneHistoryLines`
(present when the body is a pane capture whose geometry was read, like
`captureCols`/`captureRows`, and absent for a remote SSH session, whose local pane
cannot see the remote history) is the number of scrollback rows tmux holds above the
visible frame, which is the most a `full=1` request can add. `truncated` describes
the byte stream instead: for a pane with `paneHistoryLines: 0` (a fullscreen CLI in
the alternate screen) the bytes a `tail` cut dropped are earlier repaints that no
request returns. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning |
|---|---|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
## The `codeman agent` CLI (client over these endpoints)
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
A `multipart/form-data` body with one `image` part. The file is written into the
@@ -574,6 +586,64 @@ owned case is fine, its workspace is bind-mounted at the same absolute path; an
adopted container (`owned: false`) mounts nothing, so its agent can open the file
only if the container itself exposes that host path.
The optional query flag `?kind=video` selects a second path for a camera-roll
video: the file streams to disk instead of being buffered, under a far larger
per-file cap (`MAX_PASTE_VIDEO_BYTES`, 1 GB by default, env
`CODEMAN_MAX_PASTE_VIDEO_BYTES`), and only video container types are accepted
(mp4, webm, mov, m4v, ogv, single-sourced from the file viewer's playable set).
The container is checked from the first bytes as
the body arrives; a file whose bytes do not match its declared type is unlinked
and answered 415, one over the cap is unlinked and answered 413, and a transfer
that dies midway leaves nothing behind. Because one video can be 1 GB,
`?kind=video` draws on its own rate limit, 3 uploads per minute per (IP, session),
beside the 30 per minute the image path keeps; without the flag the route is
byte-identical to the image-only version, same 50 MB image cap and same 30/min
bucket. The response shape is unchanged (`path`, `filename`).
## Rendered HTML files
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
(`src/web/routes/html-view-routes.ts`).
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute,
relative to the session's workspace, or `~/` for this host's home, which a Docker
case refuses with `400` since its `~` is the container's; a non-empty string of at most 4096 characters
without NUL or line breaks, and no other keys) answers `data: { url }`, a
`/html-view/<cap>/<file>` URL. The capability grants GET access to the files under
that file's own folder, so a page's relative CSS, scripts, images and data files
resolve. It is held in memory only (a restart invalidates it), has a rolling TTL
(`CODEMAN_WEBVIEW_CAPABILITY_TTL_MS`, default 12 h), is reused for the same folder,
and is revoked on logout, admin logout and user deletion. Refusals:
- `400`: an invalid body, a remote (SSH) session, or a file that is not HTML (the
symlink-resolved name counts too);
- `404`: no such file;
- `403`: a file outside the workspace that the attachment guard blocks (a blocked
tree, or `attachmentConfineToWorkspace` on), a file in a hidden directory (inside
the workspace, one below the workspace root), or a file directly in a folder too
broad to serve recursively: `/`, the home folder, `/tmp`, `/var/tmp`, `$TMPDIR`,
the cases and user-space roots, a drive's mount point (`/mnt/d`), or any folder
above one of those (`/home`, `/Users`).
**`GET /html-view/:cap/*`** serves the page and its assets. It needs no cookie: the
capability in the path is the credential, because the page runs in an opaque origin
that never sends one. It is not under `/api`, so it has no `/api/v1` alias and no
envelope. `GET` and `HEAD` only; a CORS preflight on a live capability is answered
with `Access-Control-Allow-Methods: GET, HEAD`. What it serves: files under the
capability's folder whose path (requested and symlink-resolved) has no dot segment
and whose extension is on an asset allowlist (html, css, js/mjs, json, map, txt,
csv, md, svg and common image, font, wasm, audio, video and PDF types), never
outside the folder, never a blocked tree, and, for a folder outside the workspace,
not while `attachmentConfineToWorkspace` is on. Every response carries
`Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads`
as its whole policy (never `allow-same-origin`, so the page cannot read Codeman's
document, cookies or API, even opened as a top-level tab; and no fetch directive, so
by the owner's decision the page may load from and send requests to any host), `Cache-Control: no-store`,
`Referrer-Policy: no-referrer` and `Access-Control-Allow-Origin: *`. A `Range`
request is answered with `206` + `Content-Range` (`416` past the end), like
`file-raw`. Every refusal is a plain-text `404 Not found`; with a password set, an
unknown or revoked capability is the auth hook's `401` instead.
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
@@ -625,6 +695,52 @@ most 64 characters, but treat it as untrusted text. A `statusline` or `screen` v
persisted and restored after a server restart until the next report replaces it; a
`config` value is read again at every pane start, attach and relaunch instead.
## Prompt cache (`promptCache`)
Claude-mode session state (`GET /api/v1/sessions`) carries the main conversation's
prompt-cache state as Claude Code last reported it on the statusline (v2.1.251+), with a
change-only `session:promptCache` event (`{ sessionId, ...promptCache }`):
```json
"promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 }
```
| Field | Meaning |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `warm` | Whether the cached prefix is still within its TTL. The only field always present. |
| `ttl` | `5m` or `1h`; absent when Claude reported neither. |
| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. |
| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. |
| `misses` | Requests that re-processed content the cache already held. |
| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. |
| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. |
| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. |
| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. |
The whole object is absent until the first report, and restored after a server restart
(so the readout is not blank until the pane's next render) rather than recomputed; a value
whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm cache hits
`expiresAt`, so the cold flip arrives on its own. A post-`/clear` conversation switch drops
it. Shown in Session Options → Respawn and, for sessions without a statusline of their own,
as a `cache:until HH:MM` / `cache:cold` group in the terminal footer.
Beside it, `statusLineExporter` says whether this launch carries the statusline exporter
that sends the report, decided when the pane starts: `injected` (it does), `collection-off`
(Plan Usage was off at launch), `workspace-statusline` (the workspace's
`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or
`remote` (remote and Docker sessions). Absent when unknown.
## Placing a new session in a tab group (`tabGroupId`)
`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional
`tabGroupId` (1 to 100 characters): the id of a group in the caller's tab layout
(`GET /api/v1/tab-layout`). The session is created as usual and placed at the end
of that group in the same layout write; the response then also carries the
committed layout as `tabLayout`. An id the caller's layout does not contain (a
deleted group, another user's group) is ignored and the session lands where it
otherwise would, never a `400`. A malformed value (`null`, an empty string, a
non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
File diff suppressed because one or more lines are too long
+4
View File
@@ -168,6 +168,10 @@ automatic per-case injection on session create, into the directory each CLI scan
skills). The skill carries the guard, the safety rules, and verified
wait/orchestration recipes.
If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP
client), point it at `codeman mcp` instead: seven tools over these same endpoints,
configured with `CODEMAN_API_URL`. See the README section "MCP server".
The common ones:
```bash
+67
View File
@@ -251,6 +251,71 @@ unreachable host answers "unknown", which also means do not revive. The answer
is cached per session and cleared whenever the pane is next seen alive, so a
stale `true` from one transport drop can never revive the NEXT clean exit.
## Terminal history from the remote tmux
The local pane runs the ssh attach, so it only sees the remote tmux client's
full-screen repaints and keeps a fraction of the scrollback (none after a
reattach). A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a
remote session therefore reads the scrollback rows from the REMOTE pane in one
ssh round trip (`src/remote-pane-history.ts`) and splices them above the local
visible frame and caret. Bounded (5 s for the round trip, at most 1.5 s queued
for a slot in the shared ssh limiter) and fail-soft: any failure keeps the
local capture, and a transport failure (ssh exit 255, a timeout, output that is
not the script's) also skips that host for 30 s; a failure of the remote
command itself (a remote tmux session that is gone) affects that session only.
How much history exists is the remote tmux's own `history-limit`, which the
launch command does not set (tmux's default is 2000 lines).
The same round trip also reads the remote SCREEN, in the same tmux command list
as the history, because two seams would otherwise drop rows at the join:
- **Rows that scroll during the round trip.** The local frame is captured only
once the round trip is back, so rows that scrolled off the screen in between
are in neither the remote history nor the local frame (measured before this
read existed: 13 lines lost with a 150 ms round trip and fast output).
- **A shared remote session with a taller client.** Under `window-size latest`
a remote session attached elsewhere is sized by its latest client; when that
client is taller than this pane, the rows above this pane's frame are only
on the remote screen (measured before: 20 rows).
Both are rows of the remote screen above where the local frame begins, so the
load keeps them between the history and the frame (`joinScrollbackAndFrame`,
`findFrameInScreen` in `src/tmux-manager.ts`). The frame is placed by matching
its top rows against the screen as text (escape sequences and trailing blanks
ignored, since the two tmuxes need not encode attributes alike); the screen's
trailing blank rows and its last content row, which output in flight can still
change, are left out of the match, the overlap must be at least three rows
with two of them non-blank, and the highest match wins. A placement too low
would REPEAT the rows between it and the true one, so a shorter overlap (a lone
`$` row that happens to match after a spinner row changed) is not trusted.
When the frame cannot be placed (more output scrolled than the screen held, or
nothing matches), the join is history over frame, as without the screen read.
That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a
live region (spinner, composer, footer) pinned to the bottom and inserts new
rows above it, so the frame is not a shifted copy of the screen and is not
placed. A plain shell places, unless the frame's top row is the second half
of a wrapped line (the screen holds that line as one joined row, so nothing
matches and the join is history over frame). What is left: when the screen
repeats a whole block of rows, the frame can be placed at the higher copy,
which leaves out the rows between the two copies.
The history, the history size and the screen are ONE tmux command list
(`capture-pane … \; display-message … \; capture-pane …`), not separate `tmux`
runs. tmux runs a client's command list back to back without reading pane
output in between, so the screen begins exactly where the history ends. As
separate runs the shell kept writing between them and the rows that scrolled in
those milliseconds were in neither capture (measured with output streaming: 1-2
rows per load on a LAN host, every read on a local busy pane). This rests on how
tmux's command queue behaves, not on a documented guarantee. A random marker row
(`display-message`) separates the history from the size and the screen, since
tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no
newline after `capture-pane -p` output, so when the history ends inside a
wrapped line the marker is glued onto that row; the parser accepts the marker
at the end of a row and re-terminates the history. The remote `tail -c` bound
applies to that whole output, history first, and is the configured byte cap
plus 1 MiB for the marker, size and screen, so a cut lands in the history; the
route trims what it returns to the cap itself.
## File access over SSH
A remote case's `workingDir` is an absolute path on the **remote** host
@@ -270,6 +335,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
+18
View File
@@ -374,6 +374,23 @@ Two registration paths, with **different trust**:
file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above.
**Folder grid grants** (`POST /api/sessions/:id/folders`, `src/folder-listing.ts`)
follow the explicit-POST trust level: a deliberate, Origin‑guarded request, allowed
cross‑workspace subject to the same guard (blocklist on the folder AND on every
listed file, `confineToWorkspace` when set, hidden entries and unsupported types
left out). The grant is an id for a resolved folder; files are served by
`folderId` + a bare file name only (no `/`, `..` or NUL, supported type), and each
request re‑runs `resolveServableAttachmentPath` on the joined path, so a file that
was swapped for a symlink to a blocked target after listing is still refused.
The file a symlink resolves to is held to the attachment gate as well (supported
extension, regular file), so a supported-looking name cannot serve an arbitrary
file, and a FIFO or device node is never opened.
In multi‑user mode a regular user is held to the filesystem path picker's rule
(`folderAllowedForUser`): the folder, and again each served file after symlink
resolution, must sit inside their own `userSpacePath` or the session's
`workingDir`, else 403. User spaces live inside `homedir()`, so without it one user
could list and read another's space.
### SSE log‑tail route — intentional extra read roots
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
@@ -529,6 +546,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
- **Rendered HTML files (`/html-view/<capability>/`) are the other capability-gated route that skips cookie auth.** A clicked `.html` file renders through it so an agent's report can be viewed from any device. The exemption is narrower than the proxy's: `GET`/`HEAD` only (plus the page's CORS preflight, which `registerSecurityHeaders` answers itself with `Access-Control-Allow-Methods: GET, HEAD` and never hands to a route), the capability in the path only (no `Referer` form), and it is a separate store, so a web-tab capability never opens a page and vice versa. The capability is minted by an authenticated `POST /api/sessions/:id/html-view` (session ownership via `findSessionOrFail`, the attachment guard for out-of-workspace files), held in memory with a rolling TTL and revoked with the web-tab ones on logout, admin logout and user deletion. Every response carries `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page is opaque-origin even when opened as a top-level tab and cannot read Codeman's document, cookies or API. **Network access is open, by the owner's decision (2026-10-13):** that `sandbox` directive is the whole policy (it replaces Codeman's own `default-src 'self'` CSP and carries no fetch directive), so a rendered page behaves like a file opened from disk in a browser. What a page CAN do: load scripts, styles, fonts, images and media from any host (an agent's dashboard pulling Chart.js from a CDN), make fetch, XHR, WebSocket, beacon and form requests to any host, and read the files of its own folder through its capability. What it CANNOT do: act as Codeman's origin, read Codeman's cookies or document, use Codeman's API (a write arrives with `Origin: null` and the Origin guard refuses it; a read's response stays unreadable, because Codeman answers CORS only for its own localhost origins, and with a password set the request carries no session cookie either), or read any folder other than its own. The accepted consequence is that a page can send what it can read, its own folder, to any host, which is why the folder rules below are the boundary that matters. `Referrer-Policy: no-referrer` keeps the capability URL out of the `Referer` the page's third-party requests carry. Pinned by the exact-header test in `test/routes/html-view-routes.test.ts`. It may read its own directory tree (non-dot, asset-allowlisted, realpath-confined, blocklist applied; the dot and extension rules hold for the symlink-resolved target as well as the requested name), so minting is refused for an HTML file in a hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a directory above one (`/home`, `/Users`). Pinned by `test/routes/html-view-routes.test.ts` and the html-view block in `test/webview-auth-exemption.test.ts`.
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
---
+2
View File
@@ -46,6 +46,8 @@ Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a con
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (the first warm report whose `recache_tokens_if_cold` is null after one that carried it; `expected_rebuilds` rises only at the next request, so it is not read; never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
### Alternatives considered & rejected
| Source | Why not |
@@ -98,6 +98,52 @@ codeman agent rm "$SID" # any session except t
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
worker exited, `4` refused. `--json` prints the response's `data`.
## The MCP server
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP
client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor) can drive Codeman, from
inside a Codeman session or from an assistant on your desktop. It is a client of the same
HTTP API, with seven tools: `list_sessions`, `start_session`, `send_prompt`,
`wait_for_session`, `read_output`, `interrupt_session` and `delete_session`.
```bash
# Claude Code
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
```
```json
{
"mcpServers": {
"codeman": {
"command": "codeman",
"args": ["mcp"],
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
}
}
}
```
```toml
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
[mcp_servers.codeman]
command = "codeman"
args = ["mcp"]
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
tool_timeout_sec = 120
```
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `CODEMAN_PASSWORD` to
`env` when the server has a password.
- **It never guesses a server.** Without `CODEMAN_API_URL` (or `--url`) every call says how
to configure it.
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions it
started; listing, reading and waiting work on everything. `codeman mcp
--allow-any-session` lifts that, for an assistant that should drive your existing tabs.
- **Waits are short by default** (45 s, at most 600 s), so they fit a client's tool
timeout. A `timed_out` answer means call again.
## The manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
+8
View File
@@ -99,6 +99,14 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv
are waiting to resume. This is the single most useful setting for overnight runs on a
subscription plan.
## The prompt cache readout
**Claude only.** On the Respawn tab, above the loop controls.
Claude Code bills a turn against the cached conversation while that cache is warm, and lets the cache expire after a fixed idle time: one hour on a subscription within plan usage, five minutes on an API key, a cloud provider or extra-usage credits. The first turn after a longer break re-processes the whole context at the cache-write rate, which is why coming back to a big session after lunch costs more than the turns around it.
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
## The plan usage chip
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
+27 -3
View File
@@ -66,7 +66,7 @@ autocorrect: Enter adds a new line, and only Send delivers the text, as one past
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Image button attaches photos and puts their paths into the draft.
is parked. The editor's Image button attaches photos or videos and puts their paths into the draft.
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
editing the last queued message and walking the prompt stack.
@@ -100,13 +100,37 @@ looks exactly like a dead button.
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
into the Run dropdown.
## The Attach button
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
hand the agent a photo or a video. One tap opens the camera and photo library sheet, nothing
else: no editor, no keyboard. The chosen files upload and their paths land on the prompt, so
you then tap the terminal, write the rest of your message and press Enter. Videos (mp4, webm,
mov, m4v, ogv) upload as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the server changes it), so a long 4K clip takes a moment
on the way up.
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
not bring it back, so the tap on the terminal is how you get it again. That is the system's
behaviour around its own picker rather than a Codeman setting.
The paths go onto the prompt as unsent text, exactly like something you typed, which means
they travel with it. Tap **Compose** afterwards and the editor opens holding the paths *and*
anything you had already typed, so you can write in front of a path, fix a word, and Send.
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
so neither surface holds a half of your message. A multi-line message cannot make that trip
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
shows it is waiting. The Compose editor's own Image button remains for the times you are
already writing in the editor.
## Tapping, links and copying
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
which on a phone would unload the whole session view.
- **Tap a file path** an agent printed and the file-preview overlay opens; a log path opens the
log viewer. Works in scrolled-up transcript too.
- **Tap a file path** an agent printed and the file-preview overlay opens (Markdown rendered,
HTML as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up
transcript too.
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
because on a phone it is the only interaction that matters.
+3
View File
@@ -102,6 +102,7 @@ every session or only the active tab.
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. |
| Overview Home Screen | The phone home screen. On by default. |
### Animations
@@ -216,6 +217,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image. 50 MB by default. |
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
+13 -4
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. **Focus:** *Add to Focus* in a row's ⋯ menu (right-click on a web tab) pins a shortcut to that tab in a **Focus** section at the top of the rail, with or without groups; the tab itself stays where it is. Click a shortcut to switch to its tab, right-click it (or press the menu key) for *Remove from Focus*. Focus follows you across devices; whether it is collapsed is kept per device. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -214,17 +214,26 @@ TUIs render correctly.
Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
A fullscreen Claude tab keeps its history inside Claude, so switching back to it
reloads only the current screen, which keeps tab switches fast.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. Automatic output
history; press **Load full history** to pull the rest explicitly. That notice only
appears once you scroll to the top, leaves when you scroll back down, and stays away
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
Claude) never show it, since there is nothing more to load. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. OpenCode's wheel and swipes page its own conversation
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
too. Other CLIs scroll locally.
(PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
and leave the terminal no history of its own (an older Codex that draws inline keeps
its history in the terminal and scrolls locally). The first small movement of a
gesture pages at once, then one page per half screen of travel; one finger on the
screen is one gesture however slowly it moves. In a grid tile or the split view's
second pane the wheel pages too. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
+27 -6
View File
@@ -87,9 +87,10 @@ File paths in a session are links. That works in two places:
render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
viewer instead, which follows a file that is still being written.
working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a
page. The exception is a plain text file (a log, `.txt`, `.json`, code) inside the workspace
clicked in the terminal: that opens in the tail viewer instead, which follows a file that is
still being written. A Markdown file always opens rendered, wherever you click it.
Paths **outside** the session's workspace work too, which matters because that is where most
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
@@ -101,8 +102,24 @@ Outside the workspace the allowlist is images, video, audio, PDF, Office documen
files, where "text" is the same list the viewer will let you edit: code, config, logs, csv,
markdown. The reasoning is that a session can already `cat` any of those, so the file suffix
was never what kept anything secret; the path guard is. Types outside the list (`.svg`,
`.bmp`) say so rather than failing silently, and `.html` previews as source rather than being
rendered, so nothing served this way can execute in the page.
`.bmp`) say so rather than failing silently.
An `.html` file opens as a rendered page by default, with its relative CSS, scripts, images and
data files. The page is served from a separate `/html-view/...` address in a sandbox with no
origin of its own, so its scripts can never read Codeman's page, cookies or API.
By the project owner's decision, a rendered page has the same network access as a file you open
from disk in your browser. It **can** load scripts, styles, fonts, images and media from
anywhere on the internet (a dashboard that pulls Chart.js from a CDN works), send requests to
any host, and read the files in its own folder. It **cannot** act as Codeman: it has no access
to Codeman's page, cookies or API, and no access to any folder other than its own. Since a page
can send what it can read to any host, open HTML only from sources you trust, as you would on
your own desktop. The **Page**
pill in the viewer header switches to the source view (where **Edit** works as for any text
file), and the viewer falls back to source on its own when a page cannot be rendered: a remote
(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such
as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it
into a folder of its own). The raw file routes still serve HTML as a download only.
Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a
one-gigabyte log does not try to paint one.
@@ -155,9 +172,13 @@ expose everyone.
Paste from the clipboard or drag and drop straight onto the terminal. The image is written
where the agent can read it and the reference is inserted into your prompt. On a phone, the
image key in the keyboard bar opens the camera or photo library.
toolbar's Attach button opens the camera or photo library, videos included, and puts the path
on your prompt, where it behaves like text you typed; the Image button inside the Compose
editor does the same into the editor. Dropping a video onto the terminal works the same way.
HEIC images from an iPhone are converted to JPEG on the way in.
Videos (mp4, webm, mov, m4v, ogv) are stored as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than through server memory.
## Generated artifacts
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.41.0",
"version": "1.41.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.41.0",
"version": "1.41.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -1
View File
@@ -1,7 +1,8 @@
{
"name": "aicodeman",
"version": "1.41.0",
"version": "1.41.1",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+7
View File
@@ -133,6 +133,13 @@ its CSP for WebAssembly). Deploy = copy the bundle into Codeman's
drop point. Pinch the panel again to move it anywhere. It stays inside the
camera-owning page, so the hand keeps control (the old `window.app.detachSession`
`window.open` was a one-way trip). A small twitch-and-release cancels.
- **Tiles** (tile grid open): pinch anywhere on a tile and carry it onto
another tile (the two trade places) or an empty cell (it moves there). A
session tab carried onto a tile or an empty cell joins the grid there. With
**Detach Tiles** on (App Settings, per device, off by default) a tile let go
outside the grid opens as a window of its own, which needs the site's pop-ups
allowed (a hand is no click). The moves are tile-grid.js's own
(`tileDropTargetAt` / `dropOnTileTarget` / `detachTileAtPoint`).
- **Run / Run Shell** — pinch over the **Run** (`#runBtn`) or **Run Shell**
(`.btn-shell`) toolbar button and release in place to fire it; drifting too
far first cancels the tap. The button list is `CLICK_SELECTOR` in `entry.ts`.
+182 -6
View File
@@ -27,6 +27,14 @@
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
// in place → fires the button's real click handler. Drift too far first and
// it's treated as a stray move, not a tap.
// • Tile "grab-to-move": with the tile grid open, pinch anywhere on a tile
// and carry it onto another tile (the two trade places) or an empty cell
// (it moves there). With Detach Tiles on (App Settings, per device, off by
// default) a tile let go OUTSIDE the grid opens as a window of its own.
// A session TAB carried onto a tile or an empty cell joins the grid there,
// as a mouse-dragged tab does. Every move goes through app.js's own paths
// (tile-grid.js tileDropTargetAt / dropOnTileTarget / detachTileAtPoint), so
// the hand and the mouse can never disagree about what a drop does.
//
// Why in-page floats, not OS-window detach (decided 2026-06-08, see
// docs/MULTIMONITOR_DESIGN.md): the hand only exists in the one page that owns
@@ -52,11 +60,22 @@ declare global {
saveSubagentWindowStates?: () => void;
subagentWindowZIndex?: number;
ultracodeWindowZIndex?: number;
// The tile grid (tile-grid.js). All optional: an older dashboard without
// them simply has no tile verbs for the hand.
tileAtPoint?: (x: number, y: number) => string | null;
canGrabTile?: (id: string) => boolean;
tileDropTargetAt?: (x: number, y: number, draggedId: string) => TileTarget | null;
dropOnTileTarget?: (draggedId: string, target: TileTarget) => boolean;
isOverTileGrid?: (x: number, y: number) => boolean;
tileDetachEnabled?: () => boolean;
detachTileAtPoint?: (id: string, x: number, y: number, at?: { offsetX: number; offsetY: number }) => boolean;
};
}
}
const TAB_SELECTOR = '.session-tab';
/** A tile of the open tile grid (tile-grid.js), keyed by `data-session-id`. */
const TILE_SELECTOR = '#tileGrid .tile';
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
const PANEL_SELECTOR = '.cg-float';
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
@@ -81,11 +100,20 @@ const FLOAT_H = 420;
const DETACH_PULL_PX = 70;
/** If a button-pinch drifts more than this, it's a stray move, not a tap. */
const TAP_CANCEL_PX = 45;
/** How far (px) a grabbed tile must travel before it targets anything: a pinch
* that barely moves is a stray one, and the tile stays where it is. */
const TILE_MOVE_PX = 40;
/** The width (px) of the small copy of a grabbed tile that follows the hand. */
const TILE_GHOST_W = 280;
/** First letter colours: cyan left, violet right; green while pinching. */
const handColor = (handedness: string, pinching: boolean): string =>
pinching ? '#4ade80' : handedness === 'Right' ? '#a78bfa' : '#38bdf8';
/** Where a session carried by the hand would land in the tile grid: another
* tile, or an empty cell (tile-grid.js tileDropTargetAt). */
type TileTarget = { kind: 'tile'; id: string; el: HTMLElement } | { kind: 'cell'; cell: number; el: HTMLElement };
/** A floating in-page session panel (an iframe of `/session/:id`) the hand can
* place and re-grab. Stays in this page's DOM, so it never leaves hand reach. */
interface FloatingPanel {
@@ -107,6 +135,29 @@ type Grab =
oy: number;
/** Pulled past the float-out threshold at least once. */
armed: boolean;
/** With the tile grid open: the tile or empty cell it would join (highlighted). */
target: TileTarget | null;
}
| {
/** A tile of the open grid, carried to another cell or (Detach Tiles)
* out of the grid. The tile itself never moves until the drop: it dims
* (`tile--dragging`, the mouse drag's own class) and a small copy of
* it follows the hand. */
kind: 'tile';
id: string;
el: HTMLElement;
ghost: HTMLElement;
/** Where in the tile the hand closed, so a pop-out window lands under it. */
offsetX: number;
offsetY: number;
ox: number;
oy: number;
/** Travelled past TILE_MOVE_PX at least once. */
armed: boolean;
/** The tile or empty cell it would drop onto (highlighted). */
target: TileTarget | null;
/** Outside the grid with Detach Tiles on: letting go opens a window. */
out: boolean;
}
| {
kind: 'panel';
@@ -224,7 +275,7 @@ class GestureBridge {
await this.gc.start();
this.running = true;
this.button.classList.add('on');
this.status.textContent = 'on — pinch a tab, window, or button';
this.status.textContent = 'on: pinch a tab, tile, window, or button';
} catch (err) {
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
// (number/string), so `(err as Error).message` was logging "undefined".
@@ -295,6 +346,35 @@ class GestureBridge {
return;
}
// A tile of the open tile grid → carry it to another cell, or (Detach
// Tiles) out of the grid. Anywhere on the tile: the hand is choosing the
// whole tile, as with the agent windows. A tile that can do neither (alone
// or zoomed, Detach Tiles off) is left alone, so the pinch falls through.
const tileId = window.app?.tileAtPoint?.(x, y) ?? null;
const tileEl = tileId ? this.hitClosest(x, y, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add('tile--dragging');
this.grabs.set(hand, {
kind: 'tile',
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y - rect.top, rect.height)),
ox: x,
oy: y,
armed: false,
target: null,
out: false,
});
this.positionGhost(ghost, x, y);
this.status.textContent = 'moving tile';
return;
}
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
const tab = this.hitClosest(x, y, TAB_SELECTOR);
const id = tab?.dataset.id;
@@ -308,7 +388,7 @@ class GestureBridge {
document.body.append(ghost);
tab.classList.add('cg-grabbed');
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false, target: null });
this.positionGhost(ghost, x, y);
return;
}
@@ -325,13 +405,42 @@ class GestureBridge {
private onDrag(hand: string, x: number, y: number): void {
const grab = this.grabs.get(hand);
if (grab?.kind === 'tile') {
this.positionGhost(grab.ghost, x, y);
if (!grab.armed && Math.hypot(x - grab.ox, y - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x, y);
grab.out = out;
grab.ghost.classList.toggle('cg-armed', !!target);
grab.ghost.classList.toggle('cg-out', out);
this.status.textContent = target
? target.kind === 'tile'
? 'release to swap tiles'
: 'release to move the tile here'
: out
? 'release to open in a new window'
: 'moving tile';
return;
}
if (grab?.kind === 'tab') {
this.positionGhost(grab.ghost, x, y);
const pulled = Math.hypot(x - grab.ox, y - grab.oy) >= DETACH_PULL_PX;
if (pulled !== grab.armed) {
// The tile grid open: a tile or an empty cell under the hand takes the
// tab there, as a mouse-dragged tab does.
const target = pulled ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
grab.armed = pulled;
grab.ghost.classList.toggle('cg-armed', pulled);
this.status.textContent = pulled ? 'release to float out' : 'on — pinch a tab';
this.status.textContent = target
? 'release to tile it here'
: pulled
? 'release to float out'
: 'on — pinch a tab';
}
return;
}
@@ -354,17 +463,44 @@ class GestureBridge {
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove('cg-tap-armed');
this.taps.delete(hand);
this.status.textContent = 'on — pinch a tab, window, or button';
this.status.textContent = 'on: pinch a tab, tile, window, or button';
}
}
private onDrop(hand: string, x: number, y: number): void {
const grab = this.grabs.get(hand);
if (grab?.kind === 'tile') {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove('tile--dragging');
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash('cancelled');
return;
}
// Asked again at the drop: the grid may have changed since the last frame.
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? 'moved tile' : 'cancelled');
} else if (grab.out && !app?.isOverTileGrid?.(x, y)) {
// A window opened with no click behind it is what popup blockers stop:
// detachSession then says so in a toast and the tile stays.
const opened = app?.detachTileAtPoint?.(grab.id, x, y, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? 'opened in a new window' : 'pop-out blocked: allow popups for this site');
} else {
this.flash('cancelled');
}
return;
}
if (grab?.kind === 'tab') {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
if (grab.armed) this.floatPanel(grab.id, x, y);
this.setTileTarget(grab, null);
const target = grab.armed ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? 'tiled' : 'cancelled');
else if (grab.armed) this.floatPanel(grab.id, x, y);
else this.flash('cancelled');
return;
}
@@ -505,6 +641,33 @@ class GestureBridge {
ghost.style.top = `${y}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
private tileGhost(tileEl: HTMLElement, rect: DOMRect): HTMLElement {
const ghost = el('div', 'cg-ghost cg-tile-ghost');
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round((width * rect.height) / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector('.tile-header');
if (header) {
const copy = header.cloneNode(true) as HTMLElement;
copy.removeAttribute('draggable');
ghost.append(copy);
}
ghost.append(el('div', 'cg-tile-ghost-body'));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
private setTileTarget(grab: { target: TileTarget | null }, target: TileTarget | null): void {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove('tile--drop-target');
target?.el.classList.add('tile--drop-target');
}
grab.target = target;
}
private cancelAllGrabs(): void {
// Floats themselves persist (they're placed windows) — only release any
// in-progress grab cleanly, restoring a moved panel's interactivity.
@@ -512,6 +675,11 @@ class GestureBridge {
if (grab.kind === 'tab') {
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
this.setTileTarget(grab, null);
} else if (grab.kind === 'tile') {
grab.ghost.remove();
grab.el.classList.remove('tile--dragging');
this.setTileTarget(grab, null);
} else if (grab.kind === 'panel') {
grab.panel.el.style.pointerEvents = '';
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
@@ -525,6 +693,7 @@ class GestureBridge {
document
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t) => t.classList.remove('tile--dragging'));
}
private onStatus(fps: number, hands: HandState[]): void {
@@ -607,6 +776,13 @@ function injectStyles(): void {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.41.0",
"version": "1.41.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+18
View File
@@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman';
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json'];
// The MCP registry manifest for `codeman mcp` (published with `mcp-publisher` from its
// directory): the server and its npm package both carry the release version.
const MCP_REGISTRY_MANIFEST = 'config/mcp-registry/server.json';
const check = process.argv.includes('--check');
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
@@ -78,6 +81,21 @@ for (const file of MANIFESTS) {
if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
}
// 3. The MCP registry manifest.
{
const json = JSON.parse(readFileSync(MCP_REGISTRY_MANIFEST, 'utf8'));
const targets = [json, ...(json.packages ?? []).filter((p) => p.registryType === 'npm')];
let changed = false;
for (const target of targets) {
if (target.version !== version) {
drift.push(`${MCP_REGISTRY_MANIFEST}: ${target.version} -> ${version}`);
target.version = version;
changed = true;
}
}
if (changed && !check) writeFileSync(MCP_REGISTRY_MANIFEST, JSON.stringify(json, null, 2) + '\n');
}
if (drift.length === 0) {
console.log(`plugin in step: mirror identical, manifests at ${version}`);
} else if (check) {
+15 -3
View File
@@ -9,7 +9,8 @@
import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { basename, extname, isAbsolute, join } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js';
@@ -148,6 +149,11 @@ class AttachmentRegistry {
export const attachmentRegistry = new AttachmentRegistry();
/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
export function supportedAttachmentExtensions(): string[] {
return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
}
export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
}
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) {
// `~/…` is the home of the session's HOST: expanded here for a local session,
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
// whose home this server does not know. A clicked terminal link sends it as is
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
// resolved absolute path either way.
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
: resolveLocalAttachment(requestedPath));
: resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
+13 -8
View File
@@ -253,20 +253,21 @@ export interface RequestOptions {
headers?: Record<string, string>;
/** Socket timeout; long-polls pass their own timeout plus headroom. */
timeoutMs?: number;
/** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */
signal?: AbortSignal;
}
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
/** Every request carries these; they are ignored on endpoints that do not read them. */
export function baseHeaders(ctx: AgentContext): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/json',
// Tags sessions this caller spawns as its children (lineage in the web UI).
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
// directory as deletable agent scratch, so it rides only the spawn request that
// may create one (see agentSpawn), never anything else.
'X-Codeman-Parent-Session': ctx.selfId,
};
const headers: Record<string, string> = { Accept: 'application/json' };
// Tags sessions this caller spawns as its children (lineage in the web UI).
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
// directory as deletable agent scratch, so it rides only the spawn request that
// may create one (see agentSpawn), never anything else. Absent for a caller outside
// any session (`codeman mcp` launched by a desktop MCP client), which has no self.
if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId;
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
if (authorization) headers.Authorization = authorization;
return headers;
@@ -296,10 +297,14 @@ export const httpRequest: ApiRequest = (ctx, options) => {
rejectUnauthorized: false,
headers,
timeout: options.timeoutMs ?? 30_000,
signal: options.signal,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
// A connection dropped mid-body emits 'error' and never 'end'; without this the
// promise never settles (and a long-lived `codeman mcp` never answers the call).
res.on('error', reject);
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8');
let json: ApiEnvelope | undefined;
+26
View File
@@ -29,6 +29,7 @@ import {
import type { AgentSkillDir } from './config/cli-registry/types.js';
import { getCli, listClis } from './config/cli-registry/registry.js';
import { registerAgentCommands } from './cli-agent.js';
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -298,6 +299,12 @@ skillCmd
const target = resolveSkillTarget({ case: options.case, skillDir });
const result = await removeAgentSkillFrom(target);
if (result === 'absent') continue;
if (result === 'symlink') {
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
// skip it rather than fail the whole sweep.
console.log(palette.warn(`- Skipped ${target}: it (or its parent) is a symlink, so it was not touched`));
continue;
}
removedAny = true;
const message = describeSkillResult(result, target);
if (message.ok) console.log(palette.ok(`✓ ${message.text}`));
@@ -318,6 +325,25 @@ skillCmd
registerAgentCommands(program);
// ============ MCP server (any MCP client drives Codeman) ============
program
.command('mcp')
.description(
'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions'
)
.option('--url <url>', 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)')
.option(
'--allow-any-session',
'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)'
)
.action(async (options: { url?: string; allowAnySession?: boolean }) => {
await runMcpStdio(
resolveMcpConfig({ url: options.url, allowAnySession: options.allowAnySession, version: pkg.version })
);
process.exit(0);
});
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
+10
View File
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
*/
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
/**
* Maximum size (bytes) of a single video uploaded via POST
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
* offers the camera roll's videos beside its photos). Videos stream to disk
* rather than through memory, so this is a disk-fill sanity bound, not a memory
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
*/
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
// ============================================================================
// File Download Limits
// ============================================================================
+7
View File
@@ -297,6 +297,10 @@ const capabilitiesSchema = z
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
@@ -449,6 +453,9 @@ const capabilitiesSchema = z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Home-relative fallback used when `path` is absent. Same no-traversal rule as `path`:
// sync reads it and, when it parses as plain JSON, writes to it.
altPath: mcpRelativePath.optional(),
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
+15
View File
@@ -285,6 +285,10 @@ const CLAUDE: CliEntry = {
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
@@ -513,6 +517,11 @@ const OPENCODE: CliEntry = {
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores
// SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux
// `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the
// wheel is turned into page keys or it does nothing at all (#555).
transcriptPageKeys: true,
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
@@ -553,6 +562,7 @@ const OPENCODE: CliEntry = {
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
altPath: '.config/opencode/opencode.jsonc',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
@@ -724,6 +734,11 @@ const CODEX: CliEntry = {
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
// This is the page-key fallback for a hollow pane, not wheel forwarding.
transcriptPageKeys: true,
maxFrameBytes: 32 * 1024,
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so
// the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
+20 -1
View File
@@ -515,6 +515,20 @@ export interface CliCapabilities {
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
*
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
*
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
@@ -665,7 +679,12 @@ export interface CliCapabilities {
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
mcpConfig?: {
path: string;
altPath?: string;
format: McpConfigFormat;
relocation?: { envVar: string; path: string };
};
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+284
View File
@@ -0,0 +1,284 @@
/**
* @fileoverview Folder listings for the file-preview folder grid.
*
* A folder the user opens (a clicked folder path, or "Folder" in the file
* preview) is resolved, checked against the same attachment guard a single file
* goes through, and GRANTED an id. The grid then asks for files by
* `folderId + file name` only, so browser requests never carry arbitrary paths —
* the rule the attachment routes follow for single files.
*
* Grants live in their own small registry, not in the attachment registry: that one
* is capped at 200 records per session, and a folder of 300 renders would evict the
* session's real attachments.
*
* Listings contain non-hidden subfolders and files whose type the preview supports
* (`isSupportedAttachmentExtension`), newest first, capped at
* {@link MAX_FOLDER_ENTRIES}. Local folders are read with `fs`; a remote (SSH) case's
* folder is listed on its host in one ssh round trip (`remoteListFolder`).
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { extname, isAbsolute, join, posix } from 'node:path';
import {
getAttachmentType,
isSupportedAttachmentExtension,
supportedAttachmentExtensions,
} from './attachment-registry.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from './config/multiuser.js';
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
import type { AttachmentDetectedType, AuthUser } from './types.js';
import type { SessionRemote } from './types/session.js';
import { describeUnknownPath, probePathKind } from './utils/bounded-path-probe.js';
import { validateSessionFilePath } from './web/route-helpers.js';
/** Most entries (files + subfolders) one listing returns. */
export const MAX_FOLDER_ENTRIES = 1000;
/** Folder grants kept per session; the oldest is dropped beyond this. */
const MAX_FOLDER_GRANTS_PER_SESSION = 32;
export interface FolderFileEntry {
name: string;
extension: string;
attachmentType: AttachmentDetectedType;
size: number;
mtimeMs: number;
}
export interface FolderListing {
folderId: string;
/** The folder with symlinks resolved (on the session's host). */
path: string;
files: FolderFileEntry[];
/** Non-hidden subfolder names, sorted. */
folders: string[];
truncated: boolean;
}
export class FolderListingError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
}
}
interface FolderGrant {
folderId: string;
dir: string;
}
class FolderGrants {
private bySession = new Map<string, Map<string, FolderGrant>>();
grant(sessionId: string, dir: string): string {
let grants = this.bySession.get(sessionId);
if (!grants) {
grants = new Map();
this.bySession.set(sessionId, grants);
}
for (const existing of grants.values()) {
if (existing.dir === dir) {
// Refresh its position so an often-used folder is the last to be dropped.
grants.delete(existing.folderId);
grants.set(existing.folderId, existing);
return existing.folderId;
}
}
const folderId = `fld_${randomUUID()}`;
grants.set(folderId, { folderId, dir });
while (grants.size > MAX_FOLDER_GRANTS_PER_SESSION) {
const oldest = grants.keys().next().value;
if (oldest === undefined) break;
grants.delete(oldest);
}
return folderId;
}
get(sessionId: string, folderId: string): FolderGrant | undefined {
return this.bySession.get(sessionId)?.get(folderId);
}
clearSession(sessionId: string): void {
this.bySession.delete(sessionId);
}
}
export const folderGrants = new FolderGrants();
/**
* A file name inside a granted folder, or null when it is not one: no path
* separator, not hidden (which also rules out `.`/`..`; the listing leaves hidden
* entries out, so the routes do too), no NUL, and a type the preview supports.
*/
export function validFolderFileName(name: unknown): string | null {
if (typeof name !== 'string' || !name || name.length > 255) return null;
if (name.startsWith('.') || name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
const extension = extname(name).toLowerCase().replace(/^\./, '');
return isSupportedAttachmentExtension(extension) ? name : null;
}
/** Where a granted folder's file lives (on the session's host). */
export function folderFilePath(dir: string, name: string): string {
return posix.join(dir, name);
}
interface ListFolderOptions {
sessionWorkingDir?: string;
remote?: SessionRemote;
/** The requesting identity; in multi-user mode a regular user is held to {@link folderAllowedForUser}. */
user?: AuthUser;
}
/**
* Multi-user rule for folder grants, the same one the filesystem path picker follows:
* a regular user reaches only their own space (`userSpacePath`) or the session's
* `workingDir`. User spaces live inside `homedir()`, so without it a user could list
* and read another user's space. Always true in single-user mode and for admins; a
* remote session's folders are on its host, where only the `workingDir` applies.
*
* @param path Already resolved (realpath on the session's host).
*/
export function folderAllowedForUser(
path: string,
user: AuthUser | undefined,
scope: { workingDir?: string; remote?: SessionRemote }
): boolean {
if (!isMultiUserMode()) return true;
if (!user) return false;
if (user.role === 'admin') return true;
if (scope.remote) return !!scope.workingDir && isUnderTree(path, scope.workingDir);
return [userSpacePath(user.username), scope.workingDir].some(
(root) => !!root && !!validateSessionFilePath(root, path)
);
}
/**
* Resolve, guard, list and grant a folder for `sessionId`.
*
* A local folder is probed bounded first (`probePathKind`): one on a network mount
* that stopped answering must not freeze the server, and "did not answer" is never
* reported as "not found". The guard runs on the resolved folder before it is read.
*
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked or outside the
* user's space, 404 missing, 502 remote unreachable, 503 local folder not answering.
*/
export async function listFolder(
sessionId: string,
requestedPath: string,
options: ListFolderOptions = {}
): Promise<FolderListing> {
const homeRelative = typeof requestedPath === 'string' && (requestedPath === '~' || requestedPath.startsWith('~/'));
if (!requestedPath || typeof requestedPath !== 'string' || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new FolderListingError('Folder path must be absolute');
}
const guard = await loadAttachmentGuardConfig();
const workingDir = options.sessionWorkingDir;
const assertAllowed = (dir: string): void => {
const confined = !guard.confineToWorkspace
? true
: options.remote
? !!workingDir && isUnderTree(dir, workingDir)
: !!workingDir && !!validateSessionFilePath(workingDir, dir);
if (!confined || isBlockedAttachmentPath(dir, guard.blockedTrees)) {
throw new FolderListingError('Access to this folder is blocked', 403);
}
if (!folderAllowedForUser(dir, options.user, { workingDir, remote: options.remote })) {
throw new FolderListingError('Folder is outside your space', 403);
}
};
let dir: string;
let entries: RemoteFolderEntry[];
let truncated = false;
if (options.remote) {
const remotePath = requestedPath === '~' ? '~/' : requestedPath;
let listing;
try {
listing = await remoteListFolder(options.remote, remotePath, supportedAttachmentExtensions(), MAX_FOLDER_ENTRIES);
} catch (err) {
throw new FolderListingError(err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable', 502);
}
if (!listing) throw new FolderListingError('Folder not found', 404);
if (listing === 'not-a-folder') throw new FolderListingError('Not a folder');
({ dir, entries, truncated } = listing);
// One ssh call lists and resolves together, so the guard can only run after it;
// nothing of a refused listing leaves this function.
assertAllowed(dir);
} else {
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
const kind = await probePathKind(expanded, { pastCap: true });
if (kind === 'unknown') {
throw new FolderListingError(describeUnknownPath('Folder', expanded, { pastCap: true }), 503);
}
if (kind === 'absent') throw new FolderListingError('Folder not found', 404);
if (kind !== 'directory') throw new FolderListingError('Not a folder');
try {
dir = await fs.realpath(expanded);
} catch {
throw new FolderListingError('Folder not found', 404);
}
assertAllowed(dir);
({ entries, truncated } = await listLocalFolder(dir));
}
const files: FolderFileEntry[] = [];
const folders: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
// A secret that happens to sit in an open folder stays out of the grid too.
if (isBlockedAttachmentPath(folderFilePath(dir, entry.name), guard.blockedTrees)) continue;
if (entry.kind === 'directory') {
folders.push(entry.name);
continue;
}
const extension = extname(entry.name).toLowerCase().replace(/^\./, '');
if (!isSupportedAttachmentExtension(extension)) continue;
files.push({
name: entry.name,
extension,
attachmentType: getAttachmentType(extension),
size: entry.size,
mtimeMs: entry.mtimeMs,
});
}
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
folders.sort((a, b) => a.localeCompare(b));
return { folderId: folderGrants.grant(sessionId, dir), path: dir, files, folders, truncated };
}
/** Local counterpart of the remote listing script: subfolders + supported files, stat'ed through symlinks. */
async function listLocalFolder(dir: string): Promise<{ entries: RemoteFolderEntry[]; truncated: boolean }> {
const dirents = await fs.readdir(dir, { withFileTypes: true });
const entries: RemoteFolderEntry[] = [];
let truncated = false;
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) continue;
if (entries.length >= MAX_FOLDER_ENTRIES) {
truncated = true;
break;
}
const extension = extname(dirent.name).toLowerCase().replace(/^\./, '');
const maybeFile = isSupportedAttachmentExtension(extension);
if (!dirent.isDirectory() && !maybeFile && !dirent.isSymbolicLink()) continue;
let stat;
try {
stat = await fs.stat(join(dir, dirent.name));
} catch {
continue; // dangling symlink, or gone since readdir
}
if (stat.isDirectory()) {
entries.push({ kind: 'directory', name: dirent.name, size: 0, mtimeMs: 0 });
} else if (stat.isFile() && maybeFile) {
entries.push({ kind: 'file', name: dirent.name, size: stat.size, mtimeMs: stat.mtimeMs });
}
}
return { entries, truncated };
}
+122
View File
@@ -0,0 +1,122 @@
/**
* @fileoverview Capability tokens for rendering a local HTML file.
*
* `file-raw` never serves HTML as HTML: a page rendered from Codeman's own origin
* could read the Codeman document and drive the agent-spawning API. So an HTML
* file is rendered from `/html-view/:cap/*` instead, with a `Content-Security-
* Policy: sandbox` header that puts the document in an OPAQUE origin no matter how
* it is opened (iframe or top-level tab). That opaque origin is also why the route
* cannot authenticate on the session cookie: its subresource requests are
* cross-site, so the `SameSite=lax` cookie is never attached (the same reason the
* web-tab proxy uses capabilities, see webview-capabilities.ts).
*
* A capability is minted by an authenticated `POST /api/sessions/:id/html-view` and
* grants exactly one thing: GET access to the files in ONE directory (the HTML
* file's own), so a page's relative `style.css` / `chart.js` / images resolve.
*
* - 192 bits of `randomBytes` entropy, base64url.
* - Held in memory only. A restart invalidates every outstanding capability.
* - Rolling TTL, revoked on logout, admin logout and user deletion (`revokeOwner`).
*/
import { randomBytes } from 'node:crypto';
import { StaleExpirationMap } from './utils/index.js';
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
/** URL prefix the rendered files are served under. */
export const HTML_VIEW_PREFIX = '/html-view';
export interface HtmlViewCapabilityRecord {
/** Realpath of the directory the capability serves. */
rootDir: string;
/** Username that minted it (multi-user); undefined in single-user mode. */
owner?: string;
/**
* Whether the directory lies outside the minting session's workspace (admitted
* under the attachment guard). Recorded so the serving route can refuse it the
* moment workspace confinement is switched on, as the attachment routes do.
*/
outsideWorkspace: boolean;
createdAt: number;
}
export class HtmlViewCapabilityStore {
private readonly capabilities: StaleExpirationMap<string, HtmlViewCapabilityRecord>;
/** Reverse index so re-opening a page reuses its capability instead of leaking one per click. */
private readonly byRoot = new Map<string, string>();
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
this.capabilities = new StaleExpirationMap<string, HtmlViewCapabilityRecord>({
ttlMs,
refreshOnGet: true,
onExpire: (token, record) => {
const key = rootKey(record.rootDir, record.owner, record.outsideWorkspace);
if (this.byRoot.get(key) === token) this.byRoot.delete(key);
},
});
}
/** Mint (or reuse) a capability for a directory. Returns the token. */
mint(rootDir: string, owner?: string, outsideWorkspace = false): string {
// The flag is part of the key: an out-of-workspace mint must never reuse a
// capability recorded as in-workspace, which confinement would not stop.
const key = rootKey(rootDir, owner, outsideWorkspace);
const existing = this.byRoot.get(key);
if (existing && this.capabilities.get(existing)) return existing;
if (existing) this.byRoot.delete(key);
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
const token = randomBytes(24).toString('base64url');
this.capabilities.set(token, { rootDir, owner, outsideWorkspace, createdAt: Date.now() });
this.byRoot.set(key, token);
return token;
}
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
resolve(token: string): HtmlViewCapabilityRecord | undefined {
if (!token) return undefined;
return this.capabilities.get(token);
}
/** Revoke every capability bound to an identity (logout, admin logout, user deletion). */
revokeOwner(owner: string | undefined): number {
let revoked = 0;
for (const [key, token] of [...this.byRoot]) {
const record = this.capabilities.peek(token);
if (!record || record.owner === owner) {
this.capabilities.delete(token);
this.byRoot.delete(key);
if (record) revoked++;
}
}
return revoked;
}
get size(): number {
return this.capabilities.size;
}
dispose(): void {
this.capabilities.dispose();
this.byRoot.clear();
}
}
function rootKey(rootDir: string, owner: string | undefined, outsideWorkspace: boolean): string {
return `${owner ?? ''}\0${outsideWorkspace ? 'out' : 'in'}\0${rootDir}`;
}
/** The capability token in an `/html-view/<cap>/...` path, or null. */
export function capabilityFromHtmlViewPath(pathname: string): string | null {
if (typeof pathname !== 'string') return null;
const prefix = `${HTML_VIEW_PREFIX}/`;
if (!pathname.startsWith(prefix)) return null;
const rest = pathname.slice(prefix.length);
const slash = rest.indexOf('/');
const cap = slash === -1 ? rest : rest.slice(0, slash);
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
}
/** Process-wide store, shared by the routes and the auth middleware (see webviewCapabilities). */
export const htmlViewCapabilities = new HtmlViewCapabilityStore();
+1090
View File
File diff suppressed because it is too large Load Diff
+57 -5
View File
@@ -40,7 +40,7 @@
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { basename, dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
@@ -66,6 +66,11 @@ export interface McpSyncTarget {
label: string;
/** Home-relative default location of the config file. */
path: string;
/**
* Home-relative fallback file used when `path` is absent: read, and written in place when it
* parses (a JSONC file with comments fails the parse and is never written).
*/
altPath?: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
@@ -197,11 +202,12 @@ function toClaude(s: McpServer): Record<string, unknown> {
function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
// today. A bare `url` with no type used to be the legacy SSE form, but gemini-cli v0.63.0 tries
// it as streamable HTTP first (and logs `httpUrl` as deprecated), so map it to 'http'.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
return clean({ transport: raw.type === 'sse' ? 'sse' : 'http', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
@@ -255,7 +261,7 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
const disabled = raw.enabled === false || raw.disabled === true;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
@@ -311,6 +317,8 @@ function toCopilot(s: McpServer): Record<string, unknown> {
interface JsonDialect {
/** Key holding the server table. */
key: string;
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
nestedKey?: string;
from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
@@ -334,6 +342,7 @@ const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
},
'opencode-json': {
key: 'mcp',
nestedKey: 'servers',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
@@ -405,7 +414,17 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
if (!dialect.nestedKey) return table;
const nested = table[dialect.nestedKey];
// OpenCode reads `mcp.servers` as a V1 server named "servers" when it carries a V1 `type`.
if (nested === undefined || (isRecord(nested) && (nested.type === 'local' || nested.type === 'remote'))) return table;
if (!isRecord(nested)) throw new McpConfigError(`"${dialect.key}.${dialect.nestedKey}" is not an object`);
// Merge the direct (V1) table with the nested (V2) one. Like OpenCode, the native V2 entry wins a
// name collision; sync never writes a name already defined, so this does not affect the read-back.
const merged = dict<unknown>();
for (const k of safeKeys(table)) if (k !== dialect.nestedKey) merged[k] = table[k];
for (const k of safeKeys(nested)) merged[k] = nested[k];
return merged;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
@@ -593,6 +612,30 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
/**
* The alternate config file for a target (`McpSyncTarget.altPath`), honouring the same relocation
* as the primary file: the alt lives in the same directory, so the relocated path is the primary's
* directory plus the alt filename. Once chosen it is read and written exactly like the primary; a
* JSONC file with comments fails the parse guard and is never written.
*/
function resolveAltFile(
t: McpSyncTarget,
altPath: string,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
if (!rel || dir === undefined || dir === '') return { file: join(home, altPath) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/…`,
skip: `${rel.envVar} is set to a relative path, so the alt file cannot be located safely`,
};
}
return { file: join(dir, dirname(rel.path), basename(altPath)) };
}
/**
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
* disabled, so they are not copied. If that file cannot be read as intended the target is
@@ -667,6 +710,15 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
// A config kept under an alternate name (OpenCode's opencode.jsonc) is read when the primary
// file is absent, so its servers are synced rather than silently treated as "not installed".
if (s.t.altPath && !(await exists(s.file))) {
const alt = resolveAltFile(s.t, s.t.altPath, home, env);
if (!alt.skip && (await exists(alt.file))) {
s.file = alt.file;
s.res.file = alt.file;
}
}
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
+36
View File
@@ -25,6 +25,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
StatusLineExporter,
} from './types.js';
/**
@@ -57,6 +58,8 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
statusLine?: StatusLineExporter;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
@@ -189,6 +192,21 @@ export interface PaneCaptureOptions {
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
/**
* Full-history only: scrollback rows read from somewhere other than this pane
* (raw `capture-pane -p -e -J` text, oldest first). The pane then contributes
* only its visible frame and caret, and its own history is not read. A remote
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
*/
scrollbackOverride?: string;
/**
* With `scrollbackOverride`: the screen rows of the pane that history came
* from, read right after it. The frame this pane captures later can have
* scrolled past some of them, or show only part of a taller screen, so the
* rows above where the frame's top overlaps them are kept between the
* scrollback and the frame (`joinScrollbackAndFrame`).
*/
scrollbackOverrideScreen?: string;
/**
* Byte cap the consumer will keep from the capture. Sizes the child-process
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
@@ -204,6 +222,24 @@ export interface PaneCaptureOptions {
* rendering it needs the real height to know the frame fits.
*/
capturedGeometry?: { cols: number; rows: number };
/**
* Filled in by the implementation with the number of rows the pane holds in
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
* same query as the geometry. 0 means a full-history capture can return
* nothing beyond the visible frame: a pane in the alternate screen (a
* fullscreen CLI that keeps its transcript itself) never accumulates any.
* A full-history capture bounded by `historyLimitLines` below this depth
* left older lines behind in the multiplexer, which the caller must report
* as recoverable. Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
/**
* Filled in by the implementation with whether the pane was in the alternate
* screen at capture time, when it can tell. tmux keeps no history for such a
* pane (a nested tmux client puts it there), so a full-history capture of it
* holds one screen plus whatever the pane held before.
*/
capturedAlternateOn?: boolean;
}
/**
+126
View File
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
* `echo` (which land before the script runs) from the records without any "last N
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
*
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
* server does not know that home, and a clicked `~/…` link means the host the
* session runs on. The record's `realPath` is absolute, as for any other path.
*
* Symlink resolution is portable AND fails closed. `readlink -f` where available
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
'probe() {',
' i=$1',
' p=$2',
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
@@ -269,6 +275,126 @@ export async function remoteProbePaths(
return results;
}
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
export interface RemoteFolderEntry {
kind: 'file' | 'directory';
name: string;
size: number;
mtimeMs: number;
}
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
export interface RemoteFolderListing {
dir: string;
entries: RemoteFolderEntry[];
truncated: boolean;
}
/**
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
* one of `extensions` (any case), up to `maxEntries`.
*
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
*/
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
.join('|');
const script = [
`d=${shellescape(dir)}`,
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
"printf '\\0'",
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
'printf \'D|%s\\0\' "$r"',
'n=0',
'for f in "$r"/*; do',
' [ -e "$f" ] || continue',
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
' b=${f##*/}',
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
' [ -f "$f" ] || continue',
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
' n=$((n + 1))',
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
'done',
].join('\n');
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
// file — the same wrapper the transcript read uses.
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
*/
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
const records = stdout.split('\0').slice(1);
let dir: string | null = null;
let truncated = false;
const entries: RemoteFolderEntry[] = [];
for (const record of records) {
if (record === 'E|f') return 'not-a-folder';
if (record.startsWith('E|')) return null;
if (record.startsWith('D|')) {
dir = record.slice(2);
continue;
}
if (record === 'T|') {
truncated = true;
continue;
}
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
if (!match) continue;
const size = Number.parseInt(match[2], 10);
const mtime = Number.parseInt(match[3], 10);
entries.push({
kind: match[1] === 'd' ? 'directory' : 'file',
name: match[4],
size: Number.isFinite(size) && size > 0 ? size : 0,
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
});
}
if (!dir) {
throw new RemoteFileAccessError('remote host returned no usable folder listing');
}
return { dir, entries, truncated };
}
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
export async function remoteListFolder(
remote: SessionRemote,
dir: string,
extensions: readonly string[],
maxEntries: number
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
let stdout: string;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
);
stdout = result.stdout;
} catch (err) {
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
return parseRemoteFolderListOutput(stdout);
}
/** Read a whole remote file into memory, capped by `maxBytes`. */
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
assertNotUnderTest();
+301
View File
@@ -0,0 +1,301 @@
/**
* @fileoverview Scrollback of a remote SSH session, read from the REMOTE tmux.
*
* A remote session's local pane runs `ssh -t … tmux new-session -A` (or `attach`),
* so the local tmux only ever sees what the remote tmux CLIENT draws: full-screen
* repaints, not scrolling output. The local pane therefore keeps a fraction of the
* history (measured: 48 of 279 lines while output streamed, none after a reattach),
* while the remote pane, where the shell really runs, keeps all of it. A full-history
* load of a remote session reads the scrollback from there instead, and the caller
* splices it above the LOCAL visible frame (`PaneCaptureOptions.scrollbackOverride`):
* the local pane is what the live stream keeps drawing into, so its frame and caret
* position are the ones the browser must end up with.
*
* ONE ssh round trip reads the remote pane's scrollback rows (`capture-pane -S -<N>
* -E -1`), its `#{history_size}` and its screen. The size is load-bearing: with no
* history at all, tmux clamps `-E -1` to the first VISIBLE row and would return it
* as history. The screen closes the seam between the remote read and the local
* frame, which is read only after the round trip: rows that scrolled in between,
* and the top of a remote screen taller than the local pane, are on that screen
* alone, and the caller keeps the screen rows above where the local frame overlaps
* it (`joinScrollbackAndFrame`).
*
* ⚠️ The three reads are ONE tmux command list (`a \; b \; c`), not three tmux
* runs. tmux runs a client's command list back to back without reading pane output
* in between, so the history ends exactly where the screen begins. As separate runs
* the shell kept writing between them, and the rows that scrolled in those
* milliseconds were in neither capture (measured on a busy pane: every read lost
* rows). This rests on how tmux's command queue behaves, not on a documented
* guarantee. tmux output cannot carry the NUL markers, so a random marker line
* printed by `display-message` separates the history from the size and screen.
* tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be
* glued onto an unterminated last history row; the parser accepts it there too.
*
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
* output) returns null and the caller keeps the local capture, so a remote load is
* never worse than before. A transport failure also starts a short per-host back-off,
* so a host that is asleep costs one timeout, not one per tab switch. The child runs
* under the shared remote-ssh limiter (`remote-ssh-limiter.ts`) like every other
* short-lived remote ssh, but gives up on a slot it waited too long for (`maxWaitMs`,
* enforced in the queue), and the connection options come from
* `buildSshConnectionArgs()`.
*
* How much history exists is the REMOTE tmux's `history-limit` (2000 lines unless
* the remote user's tmux.conf raises it); the launch command does not set it.
*/
import { exec } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { promisify } from 'node:util';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { remoteTmuxLocation } from './tmux-manager.js';
const execAsync = promisify(exec);
/**
* Bound on the whole round trip. A full load waits on it, so it is far below the
* 10 s `ConnectTimeout` the shared ssh options carry: past this the local capture
* is the better answer.
*/
const REMOTE_HISTORY_TIMEOUT_MS = 5_000;
/**
* Longest a load waits for a slot in the shared ssh pool, which file previews and
* attachment probes (20 s each) also hold. Past it the local capture is used.
*/
const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500;
/** After a failed read, skip that host for this long and use the local capture. */
const REMOTE_HISTORY_BACKOFF_MS = 30_000;
/**
* Headroom over the remote `tail -c` bound for what it does not cover: the two NUL
* markers and anything a login banner or rc file prints before the script.
*/
const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024;
/**
* Added to the configured byte cap for the remote `tail -c` bound. That bound
* covers the whole tmux output, and the marker, the size and the screen come last
* (a wide, tall screen of colored rows runs to a few hundred KB); without this, a
* small cap would cut into the screen and lose the read. The route trims what it
* returns to the configured cap anyway, keeping the newest bytes.
*/
const REMOTE_SCREEN_ALLOWANCE_BYTES = 1024 * 1024;
/** The remote `tail -c` bound for a configured byte cap; 0 (unbounded) stays 0. */
export function remoteTailBytes(maxBytes: number): number {
return maxBytes > 0 ? Math.trunc(maxBytes) + REMOTE_SCREEN_ALLOWANCE_BYTES : 0;
}
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
/**
* Host id → time the back-off ends. Keyed by HOST, so a sleeping host with five
* sessions costs one timeout, and the map is bounded by the configured hosts.
*/
const backoffUntil = new Map<string, number>();
/**
* A fresh marker line for one read. Random, so no row the pane really holds can be
* taken for it.
*/
export function newScrollbackMarker(): string {
return `codeman-history-${randomBytes(8).toString('hex')}`;
}
/**
* The remote shell script. It prints a NUL, the tmux output, then a second NUL:
* the first separates a login banner or an rc-file `echo` printed before the
* script runs without guessing (the same marker `buildRemoteProbeCommand` uses),
* and both are printed whether or not tmux succeeds, so their presence says the
* script ran on the host. The tmux output is the history rows, `marker`, the
* history size, then the screen rows.
*/
export function buildRemoteScrollbackScript(
socket: string,
sessionName: string,
historyLines: number,
tailBytes: number,
marker: string
): string {
// `=name:` is an EXACT session match, then its active pane. A bare name is
// read as a pane index or window name first, so a discovered session named
// `0` would capture some other session's pane.
const target = shellescape(`=${sessionName}:`);
const lines = Math.max(1, Math.trunc(historyLines));
// The newest rows are the ones worth keeping, so the byte bound is applied on
// the remote side (`tail -c`) rather than by killing the read at maxBuffer.
// It covers the whole tmux output, history first, so with the screen
// allowance in `remoteTailBytes()` a cut lands in the history. 0 means
// unbounded, as in the route.
const bound = tailBytes > 0 ? ` | tail -c ${Math.trunc(tailBytes)}` : '';
// ONE tmux command list, so no pane output is read between the history and
// the screen (see the @fileoverview). `\;` reaches tmux as a lone `;`.
return (
`printf '\\0'; tmux -L ${shellescape(socket)} ` +
`capture-pane -p -e -J -S -${lines} -E -1 -t ${target} \\; ` +
`display-message -p -t ${target} ${shellescape(marker)} \\; ` +
`display-message -p -t ${target} '#{history_size}' \\; ` +
`capture-pane -p -e -J -t ${target}${bound}; printf '\\0'`
);
}
/** The full ssh command line for {@link buildRemoteScrollbackScript}. */
export function buildRemoteScrollbackCommand(
remote: SessionRemote,
sessionId: string,
historyLines: number,
tailBytes: number,
marker: string
): string {
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
}
/** What one round trip read from the remote pane: its scrollback rows, then its screen. */
export interface RemoteScrollback {
/** History rows above the screen, oldest first; '' when the pane has none. */
scrollback: string;
/** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */
screen: string;
}
/**
* The scrollback and screen rows from the script's stdout, or null when the
* output is not the script's or tmux printed nothing usable (no such session,
* or a byte cap so small it cut into the screen). The marker may end an
* unterminated history row (tmux before 3.2); when the output reached
* `tailBytes`, the history's partial first row is dropped.
*/
export function parseRemoteScrollbackOutput(
stdout: string,
marker: string,
tailBytes: number
): RemoteScrollback | null {
const start = stdout.indexOf('\0');
if (start === -1) return null;
const end = stdout.indexOf('\0', start + 1);
if (end === -1) return null;
// The marker ends a row, after the history's last row. Usually it is a row of
// its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so
// when the history ends inside a wrapped line (`-J` leaves it unterminated)
// the marker is glued onto that row. It is random per read, so no row the
// pane really holds ends with it.
const markerRow = `${marker}\n`;
const raw = stdout.slice(start + 1, end);
const at = raw.indexOf(markerRow);
if (at === -1) return null;
const sizeStart = at + markerRow.length;
const sizeEnd = raw.indexOf('\n', sizeStart);
if (sizeEnd === -1) return null;
const sizeText = raw.slice(sizeStart, sizeEnd).trim();
if (!/^\d+$/.test(sizeText)) return null;
const screen = raw.slice(sizeEnd + 1);
// With no history at all, tmux clamps `-E -1` to the first visible row.
if (Number(sizeText) === 0) return { scrollback: '', screen };
// A tail -c cut lands in the history (the bound leaves room for the rest),
// mid-row and possibly mid-escape, so the first, partial row is dropped. A
// cut exactly on the marker row leaves no history at all.
let scrollback = raw.slice(0, at);
if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) {
const newline = scrollback.indexOf('\n');
scrollback = newline === -1 ? '' : scrollback.slice(newline + 1);
}
if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n';
return { scrollback, screen };
}
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
export type RemoteScrollbackRunner = (command: string, opts: { timeout: number; maxBuffer: number }) => Promise<string>;
const execRunner: RemoteScrollbackRunner = async (command, opts) =>
(await execAsync(command, { encoding: 'utf-8', ...opts })).stdout;
/**
* Read a remote session's scrollback from the remote tmux, or null to keep the
* local capture. Never throws.
*
* Only a TRANSPORT failure backs the host off (ssh's own exit 255, a timeout, a
* spawn error, output that is not the script's): those say the host is
* unreachable for every session on it. A remote command failing (a session
* that is gone: tmux answers nothing between the NULs, and the closing `printf`
* hides its exit status) is about this session only, so it returns
* null and the next session on that host still tries. Same split as
* `classifyRemoteAliveExit()`.
*
* `run` is a test seam: without it nothing runs under vitest, as in
* remote-files.ts / remote-hosts.ts.
*/
export async function fetchRemoteScrollback(
remote: SessionRemote,
sessionId: string,
historyLines: number,
maxBytes: number,
run?: RemoteScrollbackRunner
): Promise<RemoteScrollback | null> {
if (!run && process.env.VITEST) return null;
const runner = run ?? execRunner;
const until = backoffUntil.get(remote.hostId);
if (until !== undefined) {
if (Date.now() < until) return null;
backoffUntil.delete(remote.hostId);
}
const marker = newScrollbackMarker();
const tailBytes = remoteTailBytes(maxBytes);
const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, tailBytes, marker);
try {
const stdout = await runWithRemoteSshLimit(
() =>
runner(command, {
timeout: REMOTE_HISTORY_TIMEOUT_MS,
maxBuffer: (tailBytes > 0 ? tailBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES,
}),
// The pool is shared with 20 s file probes; past this the local capture
// is the better answer than waiting behind them.
{ maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS }
);
const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes);
if (parsed === null) {
// Both NULs arrived, so the script ran on the host and only tmux had
// nothing to say: no such session. The script's last command is the
// closing `printf`, so this comes back as exit 0 rather than tmux's
// exit 1, and it is about this session alone. Output without the NULs
// is not the script's at all, which says the host is not answering as
// expected.
const parts = stdout.split('\0');
const ranOnHost = parts.length >= 3;
if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
// tmux printed something, but not the marker, size and screen in order.
const unreadable = ranOnHost && parts[1] !== '';
const what = !ranOnHost
? 'unexpected output'
: unreadable
? 'unreadable remote tmux output'
: 'no such remote tmux session';
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
return null;
}
return parsed;
} catch (err) {
// A busy pool says nothing about the host.
if (err instanceof RemoteSshSlotTimeoutError) return null;
const e = err as { killed?: boolean; code?: unknown };
const code = typeof e.code === 'number' ? e.code : null;
const transport = classifyRemoteAliveExit(code, e.killed === true) === undefined;
if (transport) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
const reason = e.killed ? 'timed out' : code !== null ? `exit ${code}` : 'failed';
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
return null;
}
}
/** Test hook: forget every host back-off. */
export function resetRemoteScrollbackBackoff(): void {
backoffUntil.clear();
}
+36 -6
View File
@@ -1,7 +1,9 @@
/**
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
* the buffered text read).
* the buffered text read). A remote session's full-history load reads its scrollback
* from the remote tmux through the same pool (`src/remote-pane-history.ts`), with a
* bounded wait (`maxWaitMs`) because it has the local capture to fall back on.
*
* Two paths can fan those out without a human behind each one:
*
@@ -60,12 +62,35 @@ export function getRemoteSshLimit(): number {
return MAX_CONCURRENT_REMOTE_SSH;
}
function acquire(): Promise<void> {
/** Thrown by {@link runWithRemoteSshLimit} when `maxWaitMs` passed before a slot freed up. */
export class RemoteSshSlotTimeoutError extends Error {
constructor(maxWaitMs: number) {
super(`No remote ssh slot within ${maxWaitMs} ms`);
this.name = 'RemoteSshSlotTimeoutError';
}
}
function acquire(maxWaitMs?: number): Promise<void> {
if (active < MAX_CONCURRENT_REMOTE_SSH) {
active++;
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
if (maxWaitMs === undefined) return new Promise<void>((resolve) => waiters.push(resolve));
return new Promise<void>((resolve, reject) => {
const waiter = () => {
clearTimeout(timer);
resolve();
};
// A timed-out waiter leaves the queue and is never handed a slot, so it
// owns nothing to give back: `active` is untouched on this path.
const timer = setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index === -1) return; // already handed a slot by release()
waiters.splice(index, 1);
reject(new RemoteSshSlotTimeoutError(maxWaitMs));
}, maxWaitMs);
waiters.push(waiter);
});
}
function release(): void {
@@ -78,9 +103,14 @@ function release(): void {
}
}
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
await acquire();
/**
* Run `task` once an ssh slot is free, releasing the slot afterward. With
* `maxWaitMs`, give up instead (rejecting with {@link RemoteSshSlotTimeoutError},
* `task` never runs) when no slot frees up in time: for a caller that has a
* cheaper answer than waiting behind the queue.
*/
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>, opts: { maxWaitMs?: number } = {}): Promise<T> {
await acquire(opts.maxWaitMs);
try {
return await task();
} finally {
+57
View File
@@ -63,6 +63,7 @@ import {
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
type PromptCacheStatus,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -585,6 +586,8 @@ export class Session extends EventEmitter {
* running. A session that comes back with no label has no chip on its screen.
*/
private _watching: string | null = null;
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
private _promptCache: PromptCacheStatus | null = null;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
@@ -873,6 +876,8 @@ export class Session extends EventEmitter {
paneExit?: PaneExit;
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
displayModel?: DisplayModel;
/** The previous run's last prompt-cache report, restored so the readout is not blank (see `promptCache`). */
promptCache?: PromptCacheStatus;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
@@ -1041,6 +1046,11 @@ export class Session extends EventEmitter {
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
// Last prompt-cache report, restored so the Session Options readout is not blank until
// the pane's next statusline render. The readout is time-aware, so a value whose expiry
// has passed renders as cold rather than stale-warm. Set directly, not via
// setPromptCache, so recovery does not re-stamp a compaction it did not just observe.
this._promptCache = config.promptCache ?? null;
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1244,6 +1254,11 @@ export class Session extends EventEmitter {
}
if (newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
// The conversation itself changed (a post-/clear switch), so the old prefix's cache
// report and its compaction baseline belong to a conversation that is gone. Drop them
// rather than show the previous conversation's "warm until" against the new one; the
// next statusline render repopulates. A same-id reattach returned above, untouched.
this._promptCache = null;
}
/**
@@ -1414,6 +1429,46 @@ export class Session extends EventEmitter {
return this._watching;
}
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
* report. It rides `toState()` for the Session Options readout and is restored on a
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
* as cold rather than as a stale warm.
*/
get promptCache(): PromptCacheStatus | null {
return this._promptCache;
}
/**
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
* `compactedAt` when this report ENTERS the window in which Claude Code reads
* `recacheTokensIfCold` as null: a compaction (`/compact` or its own idle compaction) or
* a clearing of old tool results opened it, and the previous report still had a known
* value. Claude Code closes the window on the next request (that request is the one that
* counts as an expected rebuild), so `expectedRebuilds` never rises inside it and is not
* read here. The stamp is carried while the window holds and dropped once it closes; a
* first report already inside the window gets none. It is never an alert.
*/
setPromptCache(status: PromptCacheStatus): boolean {
const prev = this._promptCache;
const inWindow = status.warm && status.recacheTokensIfCold == null;
let compactedAt: number | undefined;
if (inWindow && prev?.recacheTokensIfCold != null) {
compactedAt = Date.now();
} else if (inWindow && prev?.compactedAt != null) {
compactedAt = prev.compactedAt;
}
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
// route broadcasts the STORED object, so the stamp lives here and never leaks back into the
// parser's output (which would make the route's "broadcast the stored object" choice vacuous).
const next = compactedAt != null ? { ...status, compactedAt } : status;
const changed = JSON.stringify(next) !== JSON.stringify(prev);
this._promptCache = next;
return changed;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -1893,6 +1948,8 @@ export class Session extends EventEmitter {
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineExporter: this._muxSession?.statusLine,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
+68 -12
View File
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
const isRanked = (ref: TabRef): boolean =>
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
const prepare = (ref: TabRef): TabRef => {
if (ref.kind !== 'session') return { ...ref };
const item = sessionMetadata.get(ref.id);
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
};
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
const ranked = refs
.filter(isRanked)
.map(prepare)
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
let rankedIndex = 0;
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
};
const transformed: TabLayout = {
const ranked: TabLayout = {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
ungrouped: rankContainer(layout.ungrouped),
};
const manual = childrenSplitFromParent(ranked, sessionMetadata);
const pin = (ref: TabRef): TabRef =>
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
const transformed: TabLayout = {
...ranked,
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
ungrouped: ranked.ungrouped.map(pin),
};
return normalizeTabLayout(transformed, metadata);
}
/**
* The following children (no `manual` placement, an owner-valid parent) that a
* ranked layout separates from their parent's block. A parent's block is the
* parent and, right after it in the same container, its following descendants;
* sibling order inside the block is free, and a web tab the ranking left in a
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
* a PUT of the current order, or one that moves a family together, leaves every
* stored placement alone. Pinning one child shrinks its parent's block, so the
* check repeats until nothing more is pinned.
*/
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
const following = new Map<string, string>();
for (const refs of containers) {
for (const ref of refs) {
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
}
}
const descendsFrom = (id: string, ancestorId: string): boolean => {
let current = following.get(id);
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
if (current === ancestorId) return true;
current = following.get(current);
}
return false;
};
const manual = new Set<string>();
for (let changed = true; changed; ) {
changed = false;
for (const refs of containers) {
const index = new Map<string, number>();
refs.forEach((ref, at) => {
if (ref.kind === 'session') index.set(ref.id, at);
});
refs.forEach((ref, at) => {
if (ref.kind !== 'session') return;
const parentId = following.get(ref.id);
if (parentId === undefined) return;
const parentAt = index.get(parentId);
const inBlock =
parentAt !== undefined &&
parentAt < at &&
refs
.slice(parentAt + 1, at)
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
if (inBlock) return;
following.delete(ref.id);
manual.add(ref.id);
changed = true;
});
}
}
return manual;
}
export function recomposeGlobalSessionOrder(
current: readonly string[],
projections: readonly OwnerOrderProjection[],
+127 -6
View File
@@ -11,7 +11,9 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
import {
flattenOwnerSessionOrder,
materializeOrphans,
moveRef,
normalizeTabLayout,
setManualPlacement,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
@@ -42,6 +44,12 @@ interface TabLayoutServiceDeps {
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
broadcast(event: string, data: unknown): void;
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
/**
* The synced `spawnedTabsFollowParent` setting, read at every creation. Off (or
* absent), a spawned session lands after its parent once and is hand-placed
* from then on; on, it keeps following its parent until moved by hand.
*/
childrenFollowParent?(): boolean | Promise<boolean>;
now?: () => string;
}
@@ -67,6 +75,18 @@ export interface RemovedTabLayoutSession {
owner?: string;
}
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
export interface SessionPlacementHint {
tabGroupId?: string;
}
/**
* Adds the created session to the live map. Runs INSIDE the owner lock, so no
* other layout mutation can observe (and commit) the session first; the returned
* function undoes it when the layout write fails.
*/
export type RegisterCreatedSession = () => void | (() => void);
interface PreparedOwnerLayout {
current: TabLayout | null;
authoritative: TabLayout;
@@ -97,6 +117,66 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) ==
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && a.every((id, index) => id === b[index]);
/**
* Hand-place a just-created child where normalization put it (right after its
* parent), so it stops following. A session that does not follow anything is
* left alone.
*/
function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): TabLayout {
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
const parentId = item?.parentSessionId;
if (!item?.ownerValid || !parentId) return layout;
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
const ref = refs.find((candidate) => candidate.kind === 'session' && candidate.id === sessionId);
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
const pinned = setManualPlacement(layout, ref, true);
// Normalization put the child right after its parent's FOLLOWING block, i.e.
// ahead of the siblings pinned before it. Step past those (and their
// descendants) so a fan-out keeps creation order.
const parentOf = new Map<string, string>();
for (const fact of metadata) {
if (fact.kind === 'session' && fact.ownerValid && fact.parentSessionId) parentOf.set(fact.id, fact.parentSessionId);
}
const descendsFromParent = (id: string): boolean => {
let current = parentOf.get(id);
for (let hops = 0; current !== undefined && hops <= parentOf.size; hops++) {
if (current === parentId) return true;
current = parentOf.get(current);
}
return false;
};
const isChild = (candidate: TabRef): boolean => candidate.kind === 'session' && candidate.id === sessionId;
const group = pinned.groups.find((candidate) => candidate.refs.some(isChild));
const container = group ? group.refs : pinned.ungrouped;
const from = container.findIndex(isChild);
let to = from;
while (to + 1 < container.length && container[to + 1].kind === 'session' && descendsFromParent(container[to + 1].id))
to++;
if (to === from) return pinned;
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
}
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
function placeCreatedSession(
layout: TabLayout,
sessionId: string,
groupId: string,
metadata: readonly TabRefMetadata[]
): TabLayout {
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) return layout;
const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length;
try {
return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata);
} catch (error) {
// Not an owner-valid ref of this layout: the hint does not apply.
if (error instanceof TabLayoutValidationError) return layout;
throw error;
}
}
export class TabLayoutService {
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
private readonly ownerQueues = new Map<string, Promise<void>>();
@@ -320,9 +400,13 @@ export class TabLayoutService {
};
}
private async getUnlocked(owner: string): Promise<TabLayout> {
private async getUnlocked(
owner: string,
adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout
): Promise<TabLayout> {
const prepared = await this.prepareUnlocked(owner);
if (!prepared.needsReconciliationCommit) {
const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative;
if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) {
const publication = {
owner,
previous: prepared.current,
@@ -335,7 +419,7 @@ export class TabLayoutService {
return prepared.authoritative;
}
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
return this.commit(owner, base, next, prepared.metadata, prepared.current);
}
async get(owner: string): Promise<TabLayout> {
@@ -442,9 +526,46 @@ export class TabLayoutService {
}
}
/** Reconcile one completed session creation into one versioned mutation. */
async sessionCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
/**
* Reconcile one completed session creation into one versioned mutation.
*
* `placement.tabGroupId` puts the new session at the end of that group in the
* same write. The group is looked up in THIS owner's layout only, so a group id
* from another owner (or one deleted meanwhile) is simply not found and the
* session keeps its normal placement: after its parent when it has one,
* otherwise at the end of Ungrouped. An explicit group is a hand placement, so
* a child session placed this way is marked `manual` and stops following.
*
* Whether a child placed by its lineage KEEPS following is the synced
* `spawnedTabsFollowParent` setting, read here at creation: off (the default),
* the child lands after its parent once and is marked `manual` in the same
* write; on, it follows its parent until moved by hand. Flipping the setting
* later leaves existing tabs as they are.
*/
async sessionCreated(
owner: string,
sessionId?: string,
placement: SessionPlacementHint = {},
register?: RegisterCreatedSession
): Promise<TabLayout> {
return this.withOwner(owner, async () => {
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
const rollback = register?.();
try {
const groupId = placement.tabGroupId;
const adjust =
sessionId && (groupId || !follow)
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
}
: undefined;
return await this.getUnlocked(owner, adjust);
} catch (error) {
if (typeof rollback === 'function') rollback();
throw error;
}
});
}
/** Reconcile one completed saved-webview creation into one versioned mutation. */
+21 -2
View File
@@ -15,6 +15,13 @@ export interface TabRef {
kind: TabRefKind;
id: string;
placement?: 'manual';
/**
* Pinned to the vertical rail's Focus section. The browser draws a SHORTCUT to
* the ref there; the ref itself stays in its group, so the flag never moves
* anything and nothing that moves a ref may drop it. Only `true` is stored:
* an unfocused ref carries no key at all.
*/
focus?: true;
}
export interface TabGroup {
@@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef {
if (value.placement !== undefined && value.placement !== 'manual') {
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
}
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
if (value.focus !== undefined && value.focus !== true) {
throw new TabLayoutValidationError(`${label}.focus must be true when present`);
}
const ref: TabRef = { kind: value.kind, id };
if (value.placement === 'manual') ref.placement = 'manual';
if (value.focus === true) ref.focus = true;
return ref;
}
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
@@ -395,6 +408,11 @@ export function setManualPlacement(input: TabLayout, target: TabRef, manual: boo
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
}
/** Pin `target` to the Focus section, or unpin it. Moves nothing; idempotent both ways. */
export function setTabFocus(input: TabLayout, target: TabRef, focused: boolean): TabLayout {
return mapRef(input, target, ({ focus: _focus, ...ref }) => (focused ? { ...ref, focus: true } : ref));
}
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
const normalized = normalizeTabLayout(input, metadata);
if (target.kind !== 'session') {
@@ -420,7 +438,8 @@ export function followParent(input: TabLayout, target: TabRef, metadata: readonl
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
}
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
// Clear the manual placement only: a focused ref stays focused.
const cleared = mapRef(normalized, target, ({ placement: _placement, ...ref }) => ref);
return normalizeTabLayout(cleared, metadata);
}
+189 -20
View File
@@ -59,6 +59,7 @@ import {
type SessionDocker,
type DockerCommandMode,
type PaneExit,
type StatusLineExporter,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -694,19 +695,126 @@ export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */
function captureRows(text: string): string[] {
return text === '' ? [] : text.replace(/\n$/, '').split('\n');
}
/**
* Least overlap that places a frame in a screen: rows compared, and how many
* of them hold text. A placement too LOW repeats the rows between the true one
* and it, so a short overlap (one `$` or `❯` row, a rule line) is not trusted:
* past this, the plain join, which can only lose rows, is the safer answer.
*/
const FRAME_MATCH_MIN_ROWS = 3;
const FRAME_MATCH_MIN_CONTENT_ROWS = 2;
/**
* A captured row as plain text: escape sequences (SGR, OSC 8 links) and
* trailing blanks dropped. Two tmuxes render the same cell content, but need
* not encode its attributes the same way.
*/
function capturedRowText(row: string): string {
// eslint-disable-next-line no-control-regex
return row.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g, '').trimEnd();
}
/**
* Where a later frame of the same pane begins among the rows of an earlier
* screen capture, or -1 when that cannot be told.
*
* The frame's top rows must equal the screen's rows from that index on, as
* text. Two rows are left out of the comparison: the screen's trailing blank
* rows (output after the screen read fills them in the frame) and its last
* content row (output in flight may have finished it since). Every compared
* row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with
* FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins,
* so a block that merely repeats further down is only chosen when nothing
* above it matches. A row that changed between the two reads (a spinner, a
* clock) fails the true placement; the minimum keeps a lower one from being
* taken on a row or two that happen to be equal.
*
* An agent TUI mid-turn (Claude Code, codex, opencode) is not placed: it keeps
* a live region (spinner, composer, footer) pinned to the bottom and inserts new
* rows above it, so the frame is not a shifted copy of the screen and the
* comparison down to the screen's last content row fails. The caller then falls
* back to the plain join. A plain shell places, unless the frame starts on the
* second half of a wrapped line: -J holds that line as one screen row, so it
* matches nothing and the plain join applies too.
*/
export function findFrameInScreen(screenRows: string[], frameRows: string[]): number {
const screen = screenRows.map(capturedRowText);
const frame = frameRows.map(capturedRowText);
let lastContent = screen.length - 1;
while (lastContent >= 0 && screen[lastContent] === '') lastContent--;
for (let start = 0; start < lastContent; start++) {
const overlap = Math.min(lastContent - start, frame.length);
if (overlap < FRAME_MATCH_MIN_ROWS) break; // only shrinks from here on
let matches = true;
let contentRows = 0;
for (let k = 0; k < overlap && matches; k++) {
if (screen[start + k] !== frame[k]) matches = false;
else if (frame[k] !== '') contentRows++;
}
if (matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS) return start;
}
return -1;
}
/**
* Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`)
* above a visible-frame capture, as one capture in tmux's own shape: rows joined
* by a bare `\n`. Undefined or empty scrollback leaves the frame as it is.
*
* `screen` is that other pane's screen, read right after its scrollback. The
* frame is captured later, so rows can have scrolled out of it in between, and
* a frame showing part of a taller screen starts below that screen's top.
* Either way the screen rows above the frame's first row are in neither the
* scrollback nor the frame, so they go between the two. When the frame cannot
* be placed in the screen ({@link findFrameInScreen}), the join is the plain
* scrollback-over-frame one.
*/
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string, screen?: string): string {
let above = scrollback ? captureRows(scrollback) : [];
if (screen) {
const screenRows = captureRows(screen);
const frameStart = findFrameInScreen(screenRows, captureRows(frame));
if (frameStart > 0) {
// The remote screen is a capture of its own too, encoded from the default
// state: the history's last row must not leave its attributes active.
if (above.length > 0) above[above.length - 1] += '\x1b[0m';
above = above.concat(screenRows.slice(0, frameStart));
}
}
// Separate captures: tmux encodes the frame relative to the DEFAULT state, so
// the rows above it must not leave their attributes active.
return above.length > 0 ? `${above.join('\n')}\x1b[0m\n${frame}` : frame;
}
/** Pane geometry and caret position, as `display-message` reports them. */
interface PaneCursorGeometry {
cols: number;
rows: number;
cursorX: number;
cursorY: number;
/**
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
* full-history capture can add. Absent when the query did not return it.
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines?: number;
/** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */
alternateOn?: boolean;
}
/**
* Read the pane's cursor and size, or null when tmux cannot say.
*
* Every field is validated together: a caller that gets a value back can place
* a caret with it, and one that gets null must not try.
* The four geometry fields are validated together: a caller that gets a value
* back can place a caret with it, and one that gets null must not try. The
* optional fifth and sixth fields, the history depth and the alternate-screen
* flag, are only informational, so a bad one is dropped rather than
* discarding a good caret.
*/
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
let raw: string;
@@ -716,7 +824,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows, historyLines, alternateOn] = raw
.split(/\s+/)
.map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -729,7 +839,10 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
) {
return null;
}
return { cols, rows, cursorX, cursorY };
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
if (alternateOn === 0 || alternateOn === 1) geometry.alternateOn = alternateOn === 1;
return geometry;
}
/** SGR attributes, which is all `capture-pane -e` emits. */
@@ -962,6 +1075,19 @@ export function remoteTmuxSessionName(sessionId: string): string {
return `codeman-ssh-${sessionId.slice(0, 8)}`;
}
/**
* The remote tmux socket and session a remote Codeman session's pane is attached
* to, matching the command that attached it: an owned session is the one
* `buildRemoteLaunchCommand` created on the private socket, a non-owned one is the
* discovered session `buildRemoteAttachCommand` joined on `-L codeman`.
*/
export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): { socket: string; sessionName: string } {
if (remote.owned === false) {
return { socket: 'codeman', sessionName: remote.remoteSessionName || remoteTmuxSessionName(sessionId) };
}
return { socket: REMOTE_TMUX_SOCKET, sessionName: remoteTmuxSessionName(sessionId) };
}
/**
* COD-104 — build the SSH command that launches (or reattaches) a remote
* session INSIDE a tmux server on the remote host, so the remote agent survives
@@ -2039,6 +2165,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* The statusLine exporter for one claude launch, and why it is missing when it is.
* Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns
* only (remote/docker have their own separate command builders). Resolving still runs
* with collection off, because it also self-heals: it strips any legacy disk-written
* exporter from an older Codeman build the first time a session starts in that
* workspace again. `state` lands on the mux record so the Status row can
* say why no cache report will come, instead of "not reported yet" forever.
*/
private async _resolveStatusLine(
mode: SessionMode,
workingDir: string,
remoteOrDocker: boolean
): Promise<{ command?: string; state?: StatusLineExporter }> {
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
if (remoteOrDocker) return { state: 'remote' };
const enabled = await readPlanUsageTelemetryEnabled();
const command = await resolveStatusLineCliCommand(workingDir, enabled);
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
}
/**
* Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
@@ -2156,14 +2303,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// Registry-gated (capabilities.statusLineTelemetry — claude only today), local
// spawns only (remote/docker have their own separate command builders — out of
// scope here). Also self-heals: strips any legacy disk-written exporter from an
// older Codeman build the first time a session starts in that workspace again.
const statusLineCommand =
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
);
// The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting.
@@ -2327,6 +2471,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode,
attached: false,
name,
statusLine,
};
this.sessions.set(sessionId, session);
@@ -2451,11 +2596,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// See createSession()'s identical resolution for rationale.
const statusLineCommand =
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
);
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
const baseCmd = buildSpawnCommand({
@@ -2518,6 +2663,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
if (session.statusLine !== statusLine) {
session.statusLine = statusLine;
this.saveSessions();
}
return pid;
} catch (err) {
console.error('[TmuxManager] Failed to respawn pane:', err);
@@ -3870,7 +4019,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
? Math.trunc(requestedLines)
: DEFAULT_TMUX_HISTORY_LIMIT;
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
// With a scrollback override this pane's own history is not read at all:
// `-S 0` starts at the top of the visible frame.
const scrollbackOverride = fullHistory ? opts?.scrollbackOverride : undefined;
const captureFlags = !fullHistory
? 'capture-pane -p -e'
: scrollbackOverride !== undefined
? 'capture-pane -p -e -J -S 0'
: `capture-pane -p -e -J -S -${historyLines}`;
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
// (ENOBUFS) and would silently degrade full-history capture to the byte
// buffer for exactly the long sessions it exists for — size it from the
@@ -3883,14 +4039,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
const rawCapture = joinScrollbackAndFrame(
scrollbackOverride,
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
);
// Query the cursor BEFORE deciding anything else. On the full-history path
// it settles both how the capture is trimmed and whether a cursor move is
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3902,6 +4062,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
// Same query, no extra tmux call: how much scrollback a full-history pull
// could return. A pane in the alternate screen (fullscreen claude) holds
// none, and the partial-history notice must not promise it. A caller that
// bounded the capture with `historyLimitLines` also reads it to tell
// whether the bound cut anything.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
// A pane in the alternate screen keeps no history (a nested tmux client
// holds it there), so its capture is one screen however deep it reads.
if (opts && geometry?.alternateOn !== undefined) opts.capturedAlternateOn = geometry.alternateOn;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+55
View File
@@ -724,6 +724,51 @@ export interface PaneExit {
at: number;
}
/**
* Normalized prompt-cache state of a session's main conversation, from the statusline's
* `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only
* field Claude always reports: once the prefix has gone cold it sends `warm:false` with a
* null `expires_at`.
*/
export interface PromptCacheStatus {
warm: boolean;
/** Cache lifetime of the current prefix; Claude Code only ever reports these two. */
ttl?: '5m' | '1h';
/** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */
expiresAt?: number;
/** Tokens the next request re-writes if the cache has gone cold by then. */
recacheTokensIfCold?: number;
/** Requests this session that re-processed content the cache already held. */
misses?: number;
/** Cache rebuilds that followed a compaction or a clearing of old tool results. */
expectedRebuilds?: number;
/** Cache read tokens as a fraction of all input tokens this session, 0-1. */
hitRatio?: number;
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
lastMissCauses?: string[];
/**
* Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle
* compaction, or a clearing of old tool results): the first warm report whose
* `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds,
* dropped at the next request. Derived in `Session.setPromptCache`, not reported by the
* statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted
* at HH:MM"; it is never an alert.
*/
compactedAt?: number;
}
/**
* Whether this launch carries Codeman's statusLine exporter, the only source of
* `promptCache`, and why not when it does not. Decided once per claude spawn
* (`TmuxManager`), so a setting flipped later does not change it until the next launch.
* - `injected`: the exporter is on the launch.
* - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch.
* - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a
* statusLine (or could not be read), and Codeman never replaces one there.
* - `remote`: remote and Docker launches never carry the exporter.
*/
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -775,6 +820,16 @@ export interface SessionState {
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first
* report. Restored on a restart (the Session constructor's `promptCache`) so the readout
* is not blank until the pane's next render; the readout is time-aware, so a value whose
* `expiresAt` has passed reads as cold rather than as a stale warm.
*/
promptCache?: PromptCacheStatus;
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
statusLineExporter?: StatusLineExporter;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
+92 -8
View File
@@ -15,6 +15,13 @@
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
* before the first API response and for non-subscriber auth — both yield null.
*
* Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the
* main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`,
* `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset
* Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and
* `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes,
* so the footer flips to cold on its own.
*
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
* response and selects only the main `codex` bucket, excluding model-specific
* buckets. All functions are pure for testability. See
@@ -23,6 +30,10 @@
* @module usage-telemetry
*/
import type { PromptCacheStatus } from './types/index.js';
export type { PromptCacheStatus };
/** A single normalized plan-usage window. */
export interface UsageWindow {
/** Percent of the window consumed, 0–100. */
@@ -52,6 +63,24 @@ export interface RawStatuslinePayload {
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
cost?: { total_cost_usd?: number };
model?: { display_name?: string };
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
prompt_cache?: RawPromptCache | null;
}
/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */
export interface RawPromptCache {
warm?: boolean | null;
ttl?: string | null;
/** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */
expires_at?: number | null;
/** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */
recache_tokens_if_cold?: number | null;
misses?: number | null;
/** Cache rebuilds that followed a compaction or a clearing of old tool results (counted at the request after one; not read for the compaction stamp). */
expected_rebuilds?: number | null;
/** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */
hit_ratio?: number | null;
last_miss_cause?: { causes?: unknown } | null;
}
interface RawCodexRateLimitWindow {
@@ -107,6 +136,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
return t;
}
/**
* Normalize the statusline's `prompt_cache` object. Null when the payload has none
* (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not
* a boolean, so the caller can treat the cache state as unknown rather than cold.
*/
export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null {
const pc = data?.prompt_cache;
if (!pc || typeof pc.warm !== 'boolean') return null;
const s: PromptCacheStatus = { warm: pc.warm };
if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl;
if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) {
// Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to
// Infinity or lands outside the Date range after *1000, which renders "cache:until
// NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent.
const ms = Math.round(pc.expires_at * 1000);
if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms;
}
if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) {
s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold));
}
if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) {
s.misses = Math.max(0, Math.round(pc.misses));
}
if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) {
s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds));
}
if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) {
s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio));
}
const causes = pc.last_miss_cause?.causes;
if (Array.isArray(causes)) {
const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60));
if (names.length) s.lastMissCauses = names;
}
return s;
}
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
if (!value || typeof value !== 'object') return null;
@@ -140,6 +206,8 @@ export interface SessionStatus {
inputTokens?: number;
outputTokens?: number;
contextUsedPercentage?: number;
/** Prompt-cache state of the main conversation, when the statusline reports it. */
cache?: PromptCacheStatus;
}
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
@@ -166,19 +234,34 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage);
}
const cache = parsePromptCache(data);
if (cache) s.cache = cache;
return Object.keys(s).length ? s : null;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
* which live in the Codeman header chip. Claude requires a statusLine command to
* emit the rate_limits JSON at all, so this is what that command prints back
* when it has no statusline of the user's own to wrap. With nothing to show it
* returns '' rather than a brand word: a bare `codeman` on the statusline is
* the symptom discussion #405 opened with.
* `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
* once Claude reports it cold or the render happened past `expires_at`. A clock
* time rather than a countdown because the footer only re-renders on statusline
* triggers, so a countdown would sit stale on screen between turns.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
function formatCacheGroup(c: PromptCacheStatus, now: number): string {
if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold';
const d = new Date(c.expiresAt);
return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT
* the plan limits, which live in the Codeman header chip. Claude requires a
* statusLine command to emit the rate_limits JSON at all, so this is what that
* command prints back when it has no statusline of the user's own to wrap. With
* nothing to show it returns '' rather than a brand word: a bare `codeman` on the
* statusline is the symptom discussion #405 opened with. `now` is injectable so
* the cache group is testable.
*/
export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string {
if (!s) return '';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
@@ -187,6 +270,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
if (s.cache) groups.push(formatCacheGroup(s.cache, now));
return groups.length ? groups.join(' ') : '';
}
+13
View File
@@ -85,6 +85,13 @@ export interface ApprovalItem {
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
*/
options?: ApprovalOption[];
/**
* When true the dialog belongs to a third-party TUI whose keystrokes Codeman
* cannot verify (today: DeepSeek Harness / `hooks: 'supervised'`). The item
* is an ALERT only: the answer route refuses keystrokes, push omits Approve/
* Deny actions, and the UI offers "Open session" instead of answer buttons.
*/
answerInTerminal?: boolean;
}
export interface ApprovalResolvedInfo {
@@ -108,6 +115,11 @@ interface NotePromptArgs {
* opens ALREADY acknowledged: see `notePrompt()`.
*/
watching?: string | null;
/**
* Alert-only item: answer in the terminal, not via Approve/Deny. See
* `ApprovalItem.answerInTerminal`.
*/
answerInTerminal?: boolean;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
}
@@ -261,6 +273,7 @@ export class ApprovalInbox {
toolSummary: args.toolSummary,
message: args.message,
cwd: args.cwd,
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
};
if (args.kind === 'idle' && args.watching) {
item.acknowledgedAt = item.createdAt;
+32
View File
@@ -23,6 +23,7 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
capabilityFromProxyPath,
capabilityFromReferer,
@@ -183,6 +184,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
* an opaque origin, so its asset requests carry no session cookie. Read-only by
* construction (safe methods only), and the Host allowlist still applies. OPTIONS
* is the page's CORS preflight, which registerSecurityHeaders answers itself and
* never hands to a route.
*/
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') return false;
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
}
/**
* A web-tab frame that navigated itself off the proxy prefix (see
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
@@ -368,6 +383,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done();
return;
}
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) {
done();
return;
}
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -507,6 +527,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return;
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) return;
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -711,6 +733,16 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
// are not CORS-checked). Falling through lets the proxy route reply with the
// right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
// A rendered HTML page (`/html-view/<cap>/...`) is opaque-origin for the same
// reason, so a fetch() of its own `data.json` with a non-safelisted header
// preflights with `Origin: null`. Answer it here, read-only: the route serves
// GET and HEAD only, and no credentials are involved (`*` is valid for both).
if (hasValidHtmlViewCapability(req)) {
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, HEAD');
reply.header('Access-Control-Allow-Headers', '*');
reply.header('Access-Control-Max-Age', '600');
}
reply.code(204).send();
done();
return;
+8 -1
View File
@@ -5,10 +5,17 @@
import type { Session } from '../../session.js';
import type { SessionState } from '../../types.js';
import type { TabLayout } from '../../tab-layout.js';
import type { SessionPlacementHint } from '../../tab-layout-service.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): Promise<void>;
/**
* Register a created session with the owner's tab layout. `placement.tabGroupId`
* puts it at the end of that group in the same layout write (ignored when the
* group is not in the owner's layout). Resolves to the committed layout.
*/
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
+534 -39
View File
@@ -193,6 +193,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
// Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
@@ -703,6 +704,9 @@ const URL_SESSION_WAIT_MS = 30000;
*/
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
/** How long a collapsed group's header stays marked after a tab arrives in it. */
const TAB_GROUP_ARRIVAL_MS = 2000;
class CodemanApp {
constructor() {
this.sessions = new Map();
@@ -768,6 +772,12 @@ class CodemanApp {
// their scrollback can be very large; full history stays available on demand.
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
this._fullHistoryLoaded = new Set();
// Rows of scrollback tmux reported above the visible frame on this session's
// last capture (`paneHistoryLines`). 0 marks a pane with no history to load
// (a fullscreen CLI in the alternate screen), which a tab switch serves from
// the small `full=1` capture instead of the 1 MiB byte tail: see
// _notePaneHistory and selectSession.
this._paneHistoryLines = new Map(); // Map<sessionId, number>
// Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false;
@@ -1667,8 +1677,12 @@ class CodemanApp {
* side-effect-light. Calling it again for an already-open window just raises
* that window.
* @param {string} id session id
* @param {{width?: number, height?: number, left?: number, top?: number}} [placement]
* the new window's size and screen position (a detached tile opens its own
* size, where it was let go); omitted, the default 960x680 wherever the
* browser puts it. A host window ignores it.
*/
detachSession(id) {
detachSession(id, placement = null) {
if (this.isSoloWindow) return; // a solo window can't spawn more
if (!this.sessions.has(id)) return;
// Already detached → raise the existing popup instead of opening (or
@@ -1696,7 +1710,7 @@ class CodemanApp {
this._postWindowMessage({ type: 'detached', id });
return;
}
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
const features = this._detachWindowFeatures(placement);
let win = null;
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
if (!win) {
@@ -1710,6 +1724,21 @@ class CodemanApp {
try { win.focus(); } catch {}
}
/**
* The window.open features of a pop-out: `placement`'s size (default
* 960x680) and, when it has one, its screen position. Only finite numbers
* make it into the string.
*/
_detachWindowFeatures(placement) {
const int = (v) => (Number.isFinite(v) ? Math.round(v) : null);
const width = int(placement?.width) ?? 960;
const height = int(placement?.height) ?? 680;
const left = int(placement?.left);
const top = int(placement?.top);
const at = left !== null && top !== null ? `,left=${left},top=${top}` : '';
return `width=${width},height=${height}${at},menubar=no,toolbar=no,location=no,status=no`;
}
/**
* The embedding app's window opener, when there is one. A native wrapper
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
@@ -1878,11 +1907,15 @@ class CodemanApp {
if (!msg || typeof msg !== 'object') return;
if (this.isSoloWindow) {
// Roll-call has no id (broadcast to all) — answer before the id filter.
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
// A window that has handed its session back stays silent.
if (msg.type === 'roll-call') {
if (!this._soloReleased) this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
return;
}
if (msg.id !== this.soloSessionId) return;
// A host window ignores window.close()/focus() from script it did not
// open by window.open, so ask the host when it offers the call.
if (msg.type === 'close-request') { this._closeSoloWindow(); }
if (msg.type === 'close-request') { this._releaseSoloWindow(); }
else if (msg.type === 'focus-request') {
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
}
@@ -1890,6 +1923,10 @@ class CodemanApp {
}
// Dashboard side.
if (msg.type === 'detached' && msg.id) {
// A pop-out this window just docked as a tile (Detach Tiles), answering
// a roll-call on its way out: not a new pop-out, which would take the
// tile straight back off.
if (this._tileDockedRecently?.(msg.id)) return;
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
@@ -1899,6 +1936,9 @@ class CodemanApp {
} else if (msg.type === 'detach-request' && msg.id) {
// Future gesture hook: another window asks the dashboard to detach a tab.
this.detachSession(msg.id);
} else if (msg.type === 'tile-adopted' && msg.id && msg.by !== this._wsTabNonce) {
// Another window docked a tile dragged out of this one (tile-grid.js).
this._onTileAdoptedElsewhere?.(msg.id);
}
}
@@ -1945,6 +1985,41 @@ class CodemanApp {
} catch {}
}
/**
* Solo window: a dashboard took the session back (its re-dock, or a
* dashboard docked it as a tile). Says so at once and stops answering
* roll-calls, then closes. A window the browser will not let a script
* close (one opened by typing its URL) says where the session went
* instead, rather than staying a live pop-out that every dashboard would
* mark detached again at its next roll-call.
*/
_releaseSoloWindow() {
if (this._soloReleased) return;
this._soloReleased = true;
this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
this._closeSoloWindow();
setTimeout(() => {
if (!window.closed) this._showSoloReleased();
}, 300);
}
/** Solo window: the session went back to a dashboard and this window could not close. */
_showSoloReleased() {
if (document.querySelector('.solo-gone-overlay')) return;
const el = document.createElement('div');
el.className = 'solo-gone-overlay';
const title = document.createElement('h2');
title.textContent = 'Session moved';
const text = document.createElement('p');
text.textContent = 'This session is back in a Codeman window. This one can be closed.';
const btn = document.createElement('button');
btn.className = 'btn-primary';
btn.textContent = 'Close window';
btn.addEventListener('click', () => this._closeSoloWindow());
el.append(title, text, btn);
document.body.appendChild(el);
}
/** Solo window: select the target session and apply minimal single-session
* chrome. Called from handleInit once the session list has loaded. */
_applySoloMode() {
@@ -1960,6 +2035,8 @@ class CodemanApp {
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
const redock = document.getElementById('soloRedockBtn');
if (redock) redock.style.display = '';
// Detach Tiles: the title drags onto a dashboard's tiles to dock there.
this._installSoloTileHandle?.();
document.title = name + ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
// Neutralize the dashboard-only brand click in a solo window.
@@ -2300,10 +2377,13 @@ class CodemanApp {
// A session this tab is closing stays closed until the server answers.
if (this._closingSessions?.has(data.id)) return;
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
this.sessionOrder.push(data.id);
this.saveSessionOrder();
// The server placed it when it recorded the creation (a child after its
// parent, a grouped creation in its group) and broadcast that order
// (session:orderChanged); adopt it, appending only while that broadcast is
// still on its way. Never echo it back through PUT /api/session-order:
// the server reads that as a hand arrangement of every tab.
this.syncSessionOrder();
}
// Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order.
@@ -2744,7 +2824,13 @@ class CodemanApp {
const filePath = pathLink.dataset.path;
// A rendered document's links name the session the preview was opened
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
// A path the linkifier found in prose may be relative: resolve it against
// that session's working directory. A markdown document's own links
// (rebased, no `linkified` mark) already are what the routes expect.
const sessionId = pathLink.dataset.sessionId || this.activeSessionId;
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
return;
}
@@ -2862,7 +2948,8 @@ class CodemanApp {
}
/**
* Make absolute file paths in a rendered message clickable.
* Make file paths in a rendered message clickable: absolute, `~/` and
* relative ones (`findFilePathLinks`, constants.js).
*
* The terminal's link provider never sees these: the response viewer is
* markdown, and a path the agent wrote as prose or inline code renders as
@@ -2880,8 +2967,7 @@ class CodemanApp {
if (!root || typeof document === 'undefined') return;
// Guarded: a stale cached constants.js must degrade to plain text, not throw
// out of the middle of rendering a message.
if (typeof absoluteFilePathPattern !== 'function') return;
const pattern = absoluteFilePathPattern();
if (typeof findFilePathLinks !== 'function') return;
// Collect first: replacing a node while the walker is positioned on it
// invalidates the traversal.
@@ -2889,27 +2975,26 @@ class CodemanApp {
const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue;
pattern.lastIndex = 0;
if (pattern.test(node.nodeValue || '')) targets.push(node);
const found = findFilePathLinks(node.nodeValue || '');
if (found.length > 0) targets.push([node, found]);
}
for (const node of targets) {
for (const [node, found] of targets) {
const value = node.nodeValue;
const frag = document.createDocumentFragment();
let cursor = 0;
let match;
pattern.lastIndex = 0;
while ((match = pattern.exec(value)) !== null) {
const path = match[1];
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
for (const { path, index, folder } of found) {
if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
const link = document.createElement('a');
link.className = 'rv-path';
link.href = '#';
link.dataset.path = path;
link.dataset.linkified = '1';
if (folder) link.dataset.folder = '1';
link.title = path;
link.textContent = path;
frag.appendChild(link);
cursor = match.index + path.length;
cursor = index + path.length;
}
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
node.parentNode?.replaceChild(frag, node);
@@ -3203,15 +3288,16 @@ class CodemanApp {
this._terminalRefreshOwner = refreshOwner;
try {
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic
// recovery stays bounded just like normal shell selection; only the
// explicit "Load full history" action is allowed to pay for a full replay.
// recovery stays bounded just like normal shell selection (tmux's rendered
// history, SHELL_LOAD_QUERY); only the explicit "Load full history" action
// is allowed to pay for a full replay.
// TUI modes still recover the whole picture, with the downgrade guard for
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
let capture = await this._fetchTerminalCapture(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
: `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`,
{ full: useFullHistory }
);
let headersReceivedAt = capture.headersAt;
@@ -3221,6 +3307,7 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
@@ -3308,8 +3395,13 @@ class CodemanApp {
try {
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
// so it needs the full-history budget. Defaulting to the tail budget
// gave the largest payload the smallest deadline.
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
// gave the largest payload the smallest deadline. A shell reloads tmux's
// rendered history instead (SHELL_LOAD_QUERY), never the byte recording.
const shellSession = this.sessions.get(data.id)?.mode === 'shell';
const capture = await this._fetchTerminalCapture(
shellSession ? `/api/sessions/${data.id}/terminal?${SHELL_LOAD_QUERY}` : `/api/sessions/${data.id}/terminal`,
{ full: !shellSession }
);
const headersReceivedAt = capture.headersAt;
const termData = capture.json?.data ?? {};
@@ -6158,6 +6250,8 @@ class CodemanApp {
}
}
this._syncTabTreeSelection(container);
// The Focus shortcut of the selected tab is marked aria-current.
this._renderTabFocus();
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
@@ -6649,6 +6743,10 @@ class CodemanApp {
this._refreshMobileOverviewIfVisible?.();
// Same deal for the desktop home screen's tab column.
this._refreshHomeSessionsIfVisible?.();
// Focus shortcuts carry the status dot and alert of the tab they stand for.
// A no-op unless what the section would draw changed (and after a full
// rebuild above it already has).
this._renderTabFocus();
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage lines off their anchors. Only pay for it when there
@@ -6975,6 +7073,7 @@ class CodemanApp {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._noteTabGroupArrivals(container, groupProjection);
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
@@ -7001,6 +7100,10 @@ class CodemanApp {
// Set up keyboard navigation for tabs
this.setupTabKeyboardNavigation(container);
// The Focus section sits above this list in the rail, so it is drawn before
// the connection lines measure the rows.
this._renderTabFocus();
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
@@ -7249,6 +7352,38 @@ class CodemanApp {
}
}
/**
* A tab that first appears inside a COLLAPSED group (a spawned child, a session
* another device placed there) would otherwise arrive silently, the header
* count being its only trace. Its group header is marked for a moment instead
* (`.tab-layout-group-arrived`). Only a ref absent from the previous grouped
* render counts, so collapsing a group, or the first paint, marks nothing.
*/
_noteTabGroupArrivals(container, projection) {
const previous = this._renderedTabGroupRefs;
this._renderedTabGroupRefs = projection ? new Set(Object.keys(projection.sectionByRef || {})) : null;
if (!projection) return;
const now = Date.now();
const arrivals = (this._tabGroupArrivals ||= new Map());
if (previous) {
for (const [key, groupId] of Object.entries(projection.hiddenTabGroupByRef || {})) {
if (previous.has(key)) continue;
arrivals.set(groupId, now + TAB_GROUP_ARRIVAL_MS);
setTimeout(() => {
if ((arrivals.get(groupId) ?? 0) > Date.now()) return;
arrivals.delete(groupId);
for (const header of this.$('sessionTabs')?.querySelectorAll('[data-tab-group-header]') || []) {
if (header.dataset.tabGroupHeader === groupId) header.classList.remove('tab-layout-group-arrived');
}
}, TAB_GROUP_ARRIVAL_MS);
}
}
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
const until = arrivals.get(header.dataset.tabGroupHeader) ?? 0;
header.classList.toggle('tab-layout-group-arrived', until > now);
}
}
/** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return;
@@ -7535,6 +7670,9 @@ class CodemanApp {
// rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
// A Focus flag set on another device is not a structural change of the
// list, so it would not rebuild it: draw the section on its own.
else if (this._renderTabFocus()) this.updateConnectionLines?.();
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
if (next && !this._tabLayoutRestoreChecked) {
this._tabLayoutRestoreChecked = true;
@@ -7571,6 +7709,8 @@ class CodemanApp {
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
// A session spawned by another one shows up in its parent's group at once.
parents: this._tabLayoutParents(),
});
}
@@ -7608,6 +7748,226 @@ class CodemanApp {
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: the Focus section (pinned shortcuts)
// ═══════════════════════════════════════════════════════════════
//
// A ref flagged `focus: true` in the layout gets a SHORTCUT in a small
// "Focus" section pinned to the top of the vertical rail; the row itself stays
// in its group. The section is its own element in #tabRail, OUTSIDE
// #sessionTabs, on purpose: it shows above the flat rail as well as the
// grouped one, and nothing that walks #sessionTabs (Alt+N badges, the tree's
// roving stop and posinset, lineage lines, drag, the row filters, the rail
// sort) ever sees a shortcut. Its own keyboard model is a list with one tab
// stop and Up/Down/Home/End between shortcuts; each shortcut is a button, so
// Enter/Space select its tab, and the context-menu key opens "Remove from
// Focus". Collapse is per-device localStorage, like a group's.
/** True when `ref` carries `focus: true` in the held layout. */
_tabRefFocused(ref) {
const location = this._tabRefLocation(ref);
return location?.refs[location.index]?.focus === true;
}
/** Pin or unpin a row. One named operation through the edit coordinator. */
setTabRefFocus(ref, focused) {
if (!this._tabRefLocation(ref)) return false;
return this.editTabLayout({ type: 'setFocus', ref: { kind: ref.kind, id: ref.id }, focused: focused === true }, `${ref.kind}:${ref.id}`);
}
/** The row menu's Focus entry ([] where the rail cannot edit the layout). */
_tabRefFocusActions(ref) {
if (!this._tabLayoutEditable() || !this._tabRefLocation(ref)) return [];
const focused = this._tabRefFocused(ref);
return [{ label: focused ? 'Remove from Focus' : 'Add to Focus', run: () => this.setTabRefFocus(ref, !focused) }];
}
_tabFocusCollapsedNow() {
if (this._tabFocusCollapsed === undefined) {
const storage = this._getTabCollapseStorage();
const loaded = storage ? window.CodemanTabLayout.loadFocusCollapsed(storage) : { collapsed: false, ok: false };
if (!loaded.ok) this._tabCollapseStorageFailed = true;
this._tabFocusCollapsed = loaded.collapsed;
}
return this._tabFocusCollapsed;
}
/** Collapse/expand the Focus section. Per-device; a store failure leaves it expanded. */
toggleTabFocusCollapsed(forceCollapsed) {
const shouldCollapse = forceCollapsed === undefined ? !this._tabFocusCollapsedNow() : forceCollapsed === true;
const storage = this._getTabCollapseStorage();
const saved = storage ? window.CodemanTabLayout.saveFocusCollapsed(storage, shouldCollapse) : { collapsed: false, ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this._tabFocusCollapsed = saved.collapsed;
if (this._renderTabFocus()) this.updateConnectionLines?.();
document.getElementById('tabFocusToggle')?.focus();
return this._tabFocusCollapsed === shouldCollapse;
}
/** The focused refs this rail draws now ([] off the vertical rail or without a layout). */
_tabFocusRefs() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return [];
return window.CodemanTabLayout.focusRefs(this.tabLayout, {
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
});
}
/** The Focus section element, created once inside #tabRail just above the list. */
_tabFocusHost() {
let host = document.getElementById('tabFocus');
if (host) return host;
const rail = document.getElementById('tabRail');
if (!rail) return null;
host = document.createElement('section');
host.id = 'tabFocus';
host.className = 'tab-focus';
host.hidden = true;
host.setAttribute('aria-labelledby', 'tabFocusTitle');
// Delegated, so the listeners survive every repaint of the section.
host.addEventListener('keydown', (event) => this._handleTabFocusKeydown(event));
host.addEventListener('click', (event) => {
if (event.target?.closest?.('#tabFocusToggle')) {
this.toggleTabFocusCollapsed();
return;
}
const item = event.target?.closest?.('.tab-focus-item');
if (item) this.openTabFocusShortcut(event, item.dataset.focusKey);
});
host.addEventListener('contextmenu', (event) => {
const item = event.target?.closest?.('.tab-focus-item');
if (item) this.openTabFocusMenu(event, item.dataset.focusKey, item);
});
const list = this.$('sessionTabs');
if (list && list.parentElement === rail) rail.insertBefore(host, list);
else rail.appendChild(host);
return host;
}
/**
* Draw the Focus section from the held layout. Cheap enough for every render
* pass: the markup is compared with what was drawn last, and the DOM is only
* touched when it changed. Returns true when it did (the rail's list moved,
* so connector lines anchored to its rows need a redraw).
*/
_renderTabFocus() {
const refs = this._tabFocusRefs();
const host = refs.length ? this._tabFocusHost() : document.getElementById('tabFocus');
if (!host) return false;
if (!refs.length) {
if (host.hidden && !host.firstChild) return false;
host.hidden = true;
host.replaceChildren();
this._lastTabFocusHtml = '';
return true;
}
// Keep the rail's list just below the section: applyTabOrientation()
// re-appends #sessionTabs to the rail when the orientation flips.
const list = this.$('sessionTabs');
const rail = host.parentElement;
if (list && rail && list.parentElement === rail && host.nextElementSibling !== list) rail.insertBefore(host, list);
const collapsed = this._tabFocusCollapsedNow();
const activeKey = this.activeWebviewId
? `webview:${this.activeWebviewId}`
: this.activeSessionId
? `session:${this.activeSessionId}`
: '';
const focusedEl = host.contains(document.activeElement) ? document.activeElement : null;
const focusedKey = focusedEl?.dataset?.focusKey || (focusedEl?.id === 'tabFocusToggle' ? 'toggle' : null);
const keys = refs.map((ref) => `${ref.kind}:${ref.id}`);
// One tab stop in the list: the shortcut that had it, else the selected
// tab's, else the first.
const stopKey = [focusedKey, this._tabFocusStopKey, activeKey].find((key) => key && keys.includes(key)) || keys[0];
const items = refs
.map((ref) => {
const key = `${ref.kind}:${ref.id}`;
let name;
let statusHtml;
let alertClass = '';
if (ref.kind === 'session') {
const session = this.sessions.get(ref.id);
name = this.getSessionName(session);
statusHtml = `<span class="tab-status ${escapeHtml(session.status || 'idle')}" aria-hidden="true"></span>`;
const alert = this.tabAlerts?.get(ref.id);
alertClass = alert === 'action' ? ' tab-alert-action' : alert === 'idle' ? ' tab-alert-idle' : '';
} else {
const webview = this.webviews.get(ref.id);
name = webview?.name || ref.id;
statusHtml = `<span class="tab-web-icon" aria-hidden="true">${webview?.icon ? escapeHtml(webview.icon) : this._webviewGlobeIcon?.() || ''}</span>`;
}
// The name is user text: the whole button is kept away from the
// translator (a session named "3 sessions" must not come out in Chinese).
return (
`<li class="tab-focus-entry"><button type="button" class="tab-focus-item${alertClass}" data-focus-key="${escapeHtml(key)}" data-i18n-skip ` +
`tabindex="${key === stopKey ? '0' : '-1'}" aria-current="${key === activeKey ? 'true' : 'false'}" aria-label="${escapeHtml(name)}" title="${escapeHtml(name)}">` +
`${statusHtml}<span class="tab-focus-name">${escapeHtml(name)}</span></button></li>`
);
})
.join('');
const html =
`<button type="button" id="tabFocusToggle" class="tab-focus-toggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="tabFocusList">` +
`<span class="tab-layout-group-chevron" aria-hidden="true"></span><span class="tab-focus-title" id="tabFocusTitle">Focus</span>` +
`<span class="tab-focus-count" aria-hidden="true">${refs.length}</span></button>` +
`<ul class="tab-focus-list" id="tabFocusList" aria-labelledby="tabFocusTitle"${collapsed ? ' hidden' : ''}>${items}</ul>`;
if (!host.hidden && this._lastTabFocusHtml === html) return false;
host.classList.toggle('tab-focus--collapsed', collapsed);
host.innerHTML = html;
host.hidden = false;
this._lastTabFocusHtml = html;
if (focusedKey === 'toggle') document.getElementById('tabFocusToggle')?.focus();
else if (focusedKey) host.querySelector(`[data-focus-key="${CSS.escape(focusedKey)}"]`)?.focus();
return true;
}
_tabFocusRefFromKey(key) {
const at = typeof key === 'string' ? key.indexOf(':') : -1;
if (at < 0) return null;
const kind = key.slice(0, at);
return kind === 'session' || kind === 'webview' ? { kind, id: key.slice(at + 1) } : null;
}
/** A shortcut was clicked: select its tab, exactly as the row would. */
openTabFocusShortcut(event, key) {
const ref = this._tabFocusRefFromKey(key);
if (!ref) return undefined;
this._tabFocusStopKey = key;
if (ref.kind === 'webview') return this.handleWebviewTabClick?.(event, ref.id);
return this.handleSessionTabClick(event, ref.id);
}
/** Right-click, Shift+F10 or the menu key on a shortcut (`trigger` anchors the menu). */
openTabFocusMenu(event, key, trigger) {
const ref = this._tabFocusRefFromKey(key);
if (!ref) return false;
this._tabFocusStopKey = key;
// A delegated listener's currentTarget is the section; anchor on the shortcut.
const anchored = trigger
? { preventDefault: () => event?.preventDefault?.(), stopPropagation: () => event?.stopPropagation?.(), currentTarget: trigger, type: event?.type, clientX: event?.clientX }
: event;
return this._openTabLayoutMenu(anchored, `focus:${key}`, 'Focus actions', [
{ label: 'Remove from Focus', run: () => this.setTabRefFocus(ref, false) },
]);
}
/** Up/Down/Home/End move between shortcuts; the list keeps one tab stop. */
_handleTabFocusKeydown(event) {
const current = event.target?.closest?.('.tab-focus-item');
if (!current || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const items = [...event.currentTarget.querySelectorAll('.tab-focus-item')];
const index = items.indexOf(current);
const next =
event.key === 'Home'
? items[0]
: event.key === 'End'
? items[items.length - 1]
: items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
if (!next) return;
event.preventDefault();
for (const item of items) item.tabIndex = item === next ? 0 : -1;
this._tabFocusStopKey = next.dataset.focusKey;
next.focus();
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: editing groups from the vertical rail
// ═══════════════════════════════════════════════════════════════
@@ -7771,8 +8131,8 @@ class CodemanApp {
if (!this._tabLayoutEditable()) return [];
const location = this._tabRefLocation(ref);
if (!location) return [];
const actions = [];
const grouped = this.tabLayout.groups.length > 0;
const actions = grouped ? this._tabRefPlacementActions(ref) : [];
// Up/down follow the STORED order, which is what the rail paints unless a
// sort is on (then the sort decides and there is nothing to reorder).
if (grouped && !this.isTabRailSorted()) {
@@ -7797,6 +8157,55 @@ class CodemanApp {
return actions;
}
/**
* A child session's placement, for its row menu: an informational note (where
* it sits and why: plain text, never focusable) and, for a child placed by hand, "Follow
* parent again". A child follows its parent into the parent's group until it
* is moved by hand; a child whose parent is gone stands on its own and is
* never re-adopted automatically.
*/
_tabRefPlacementActions(ref) {
// Following is the synced "Spawned tabs follow their parent" setting (off by
// default): off, every spawned tab is hand-placed, so there is nothing to say.
if (this.loadAppSettingsFromStorage?.()?.spawnedTabsFollowParent !== true) return [];
const parents = this._tabLayoutParents();
const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents);
if (!placement) return [];
const parent = this.sessions.get(placement.parentId);
const parentName = parent ? this.getSessionName(parent) : placement.parentId;
const summary = {
inherited: `Follows ${parentName}`,
manual: `Placed by hand (parent: ${parentName})`,
dangling: 'Parent closed; placed on its own',
cycle: 'Parent loop; placed on its own',
}[placement.state];
const actions = [{ label: summary, note: true }];
if (placement.canFollow) {
actions.push({
label: 'Follow parent again',
run: () => this.editTabLayout({ type: 'followParent', ref, parents: this._tabLayoutParents() }, `${ref.kind}:${ref.id}`),
});
}
return actions;
}
/**
* The group menu's "New session": launch ONE session with the current case and
* run mode, placed at the end of this group by the server in the same write
* that records it (the group id rides the create request). A collapsed group
* expands first so the new tab is visible when it arrives.
*/
async createSessionInTabGroup(groupId) {
if (!this._tabLayoutEditable() || !this.tabLayout.groups.some((group) => group.id === groupId)) return false;
if (this._runInFlight) {
this.showToast?.('A session is already starting.', 'info');
return false;
}
this.toggleTabGroupCollapsed(groupId, false);
await this.run({ count: 1, tabGroupId: groupId });
return true;
}
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/**
@@ -7819,10 +8228,11 @@ class CodemanApp {
menu.remove();
if (!restoreFocus) return;
// The trigger may have been re-rendered while the menu was open; find the
// live tree item by identity.
// live tree item by identity. A flat-rail row (a web tab's menu opens there
// too, from ContextMenu or Shift+F10) is a `.session-tab` with role=tab.
const rail = this.$('sessionTabs');
const item =
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
(trigger?.isConnected && trigger.closest('[role="treeitem"], .tab-focus-item, .session-tab')) ||
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
item?.focus();
}
@@ -7837,6 +8247,7 @@ class CodemanApp {
const index = groups.findIndex((group) => group.id === groupId);
if (index < 0) return false;
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
{ label: 'New session', run: () => this.createSessionInTabGroup(groupId) },
...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
@@ -7848,7 +8259,8 @@ class CodemanApp {
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
openTabWebviewMenu(event, webviewId) {
const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId });
const ref = { kind: 'webview', id: webviewId };
const moves = [...this._tabRefFocusActions(ref), ...this._tabRefMoveActions(ref)];
if (!moves.length) {
this.showWebviewModal?.(webviewId);
return false;
@@ -8740,6 +9152,7 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -8874,9 +9287,14 @@ class CodemanApp {
source: payload.source ?? null,
fullSize: payload.fullSize ?? 0,
retainedBytes: payload.retainedBytes ?? 0,
// tmux holds lines beyond a `lines=` window, so fullSize undercounts.
historyLinesCut: !!payload.historyLinesCut,
// Set once a full-history pull has been refused as a downgrade: the
// browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted,
// tmux scrollback above the frame (null = not reported). 0 is a pane with
// nothing a pull could add, which hides the notice outright.
paneHistoryLines: payload.paneHistoryLines ?? null,
});
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
@@ -8884,9 +9302,45 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
this._paneHistoryLines?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* Remember how much scrollback tmux holds for a session's pane, from any
* terminal response. Called whether or not the response was written, since
* it describes the PANE, not the payload. A response without the field (a
* byte-history fallback, an older server) forgets it, so the next tab switch
* goes back to the bounded tail: unknown never counts as empty.
*/
_notePaneHistory(sessionId, payload) {
if (!sessionId) return;
const lines = payload?.paneHistoryLines;
if (typeof lines === 'number' && Number.isFinite(lines) && lines >= 0) {
(this._paneHistoryLines ||= new Map()).set(sessionId, lines);
} else {
this._paneHistoryLines?.delete(sessionId);
}
}
/**
* Bring the partial-history notice up for `sessionId`, or retire it (null).
*
* The notice is LAZY: a tail replay is truncated on nearly every tab switch,
* and a bar over the top rows on every switch described history the user had
* not reached for. It now waits for the scroll gesture that reaches the top of
* the browser's buffer (the moment the missing part matters, and the same
* gesture that already re-pulls history), and goes away again once the user
* scrolls back down to live output. A tab switch retires it (selectSession).
*/
_setHistoryNoticeRevealed(sessionId) {
const next = sessionId || null;
if ((this._historyNoticeRevealedFor ?? null) === next) return;
this._historyNoticeRevealedFor = next;
this._renderHistoryTruncationBanner();
}
/**
* Paint the partial-history banner for the active session.
*
@@ -8896,13 +9350,22 @@ class CodemanApp {
* - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling
*
* Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled
* to the top of this tab's buffer) and never again for a session whose notice
* the user dismissed on this page.
*/
_renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar');
if (!bar) return;
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
const sessionId = this.activeSessionId;
const state = sessionId ? this._historyTruncation?.get(sessionId) : null;
const notice = computeHistoryTruncationNotice(state || {});
if (!notice.visible) {
if (
!notice.visible ||
this._historyNoticeRevealedFor !== sessionId ||
this._historyNoticeDismissed?.has(sessionId)
) {
bar.hidden = true;
return;
}
@@ -8935,6 +9398,9 @@ class CodemanApp {
dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×';
dismiss.onclick = () => {
// Sticky for this session until the page reloads: a dismissed notice used
// to come straight back on the next tab switch.
(this._historyNoticeDismissed ||= new Set()).add(sessionId);
bar.hidden = true;
};
bar.appendChild(dismiss);
@@ -9048,7 +9514,9 @@ class CodemanApp {
this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258).
// session's notice stays on screen until then (#258). The switch lands at
// live output, so the notice waits for a scroll to the top again.
this._historyNoticeRevealedFor = null;
this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming
@@ -9317,16 +9785,41 @@ class CodemanApp {
// TUI sessions still get one canonical full replay per page (COD-47/#205).
// A shell can retain hundreds of thousands of plain scrollback lines, so
// automatically replaying all of them makes tab selection scale with the
// entire session. Load its bounded 1MB tail first; the existing truncation
// banner action fetches ?full=1 when the user explicitly asks for it.
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
// entire session. Load a bounded window of tmux's RENDERED history first
// (SHELL_LOAD_QUERY: never the raw byte recording, whose repaints replay
// wrong at another pane height); the existing truncation banner action
// fetches ?full=1 when the user explicitly asks for it.
//
// A TUI pane whose last capture reported NO tmux scrollback (fullscreen
// claude: it lives in the alternate screen and keeps its transcript itself)
// takes `full=1` on every switch, not only the first. For such a pane that
// capture IS the visible frame, a few KB, while the tail is 1 MiB of the
// byte stream's old repaints. Measured on 11 live fullscreen claude panes:
// the tail took 250-1070 ms on the server and 70-510 ms to parse, and
// rendered one frame repeated (450 rows, 44 distinct); `full=1` took
// 150-330 ms and returned 0.9-5.6 KB. It also matches what a page load
// already shows, and an empty local buffer is what lets
// _maybePageCliTranscript send a wheel to the CLI's own transcript. The
// response re-reports the count, so a pane that starts keeping history
// (claude switched to its inline view) goes back to the tail on the next
// switch.
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
// alternate screen, so it reports 0 while its byte tail is real scrollback.
const shellSession = session?.mode === 'shell';
const paneKeepsNoHistory =
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
let capture;
try {
capture = await this._fetchTerminalCapture(
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: shellSession
? `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`
: tailUrl,
{ full: useFullHistory }
);
} catch (err) {
@@ -9349,6 +9842,7 @@ class CodemanApp {
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
@@ -9649,8 +10143,9 @@ class CodemanApp {
//
// UNREACHABLE as written, and kept for the invariant rather than the
// branch. A `useFullHistory` pass sends `full=1`, and the route answers
// `full=1` with `mux-full-history` or `history`, never `mux-visible`
// (see the source ladder in session-routes.ts), so the gate above
// a `full=1` pass without `alt=recording` (every `useFullHistory` pass;
// only shell loads send it) with `mux-full-history` or `history`, never
// `mux-visible` (see the source ladder in session-routes.ts), so the gate above
// already rules out every pass that consumed the flag. Do not read this
// line as evidence that a page load retries: it does not, and the test
// suite pins that it does not.
@@ -9793,8 +10288,8 @@ class CodemanApp {
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) {
// Not saved: the server's deletion already re-projected and broadcast the order.
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
+4
View File
@@ -261,6 +261,10 @@ Object.assign(CodemanApp.prototype, {
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
`</div>`;
} else if (item.answerInTerminal) {
// Supervised-hook CLIs (DeepSeek Harness): keystrokes are unverifiable, so
// Approve/Deny would hit the answer route and fail. Offer only Open session.
actions = `<span class="approval-terminal-hint" data-i18n-skip>Answer in the terminal</span>`;
} else if (item.options && item.options.length) {
actions = item.options
.map(
+395 -7
View File
@@ -116,6 +116,21 @@ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / bro
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
// A shell's automatic loads (tab select, drop recovery, a tile's load) read
// tmux's RENDERED history, never the raw byte recording: that recording holds
// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
// the moment, and replayed into a terminal of another height each repaint
// scrolls its top row into scrollback, so one line shows up dozens of times.
// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
// the older lines as before. `alt=recording`: a pane in the alternate screen (a
// nested tmux, as in remote SSH and Docker cases, but also vim or less while
// open) gains no tmux history, so the server loads it the old way, visible
// frame over the tailed byte recording.
// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
const SHELL_LOAD_HISTORY_LINES = 10000;
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
@@ -709,6 +724,45 @@ function computeConnectionLossUi(input) {
};
}
// Session Options → Respawn → "Prompt cache": one sentence of the main
// conversation's cache state as Claude Code reports it on the statusline
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
// because the row refreshes on SSE, not on a timer, and a countdown would sit
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
// session's `statusLineExporter`: a launch without the exporter never reports, so
// it says why instead of "not reported yet" forever.
const PROMPT_CACHE_NOT_COLLECTED = {
'collection-off':
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
'workspace-statusline':
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
remote: 'Not collected for remote and Docker sessions.',
};
function formatPromptCacheStatus(cache, now, statusLine) {
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
if (!cache || typeof cache.warm !== 'boolean') {
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
}
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
const stake = cache.recacheTokensIfCold;
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
const parts = [];
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
} else {
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
}
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
// request; naming when it happened explains the gap. Never an alert.
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
return parts.join(' ');
}
// SSE staleness policy: is this stream a zombie?
//
// An EventSource that stops delivering does not always error. A proxy that
@@ -1447,6 +1501,9 @@ if (typeof window !== 'undefined') {
compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
anchor: sessionActivityAnchor,
@@ -1507,6 +1564,15 @@ function scheduleBackground(fn) {
// but server-sent terminal buffers may still contain markers from Claude CLI.
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
// OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
// xterm parses it after chunkedTerminalWrite has already released the load
// gate, so an OSC 52 left in would re-copy old text on every tab switch. The
// 8-bit C1 forms (OSC \x9d, ST \x9c) too: xterm.js still parses those.
const OSC52_STRIP_RE = /(?:\x1b\]|\x9d)52;[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g;
// The same OSC 52 text arriving again within this window is one copy delivered
// several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
const OSC52_DEDUPE_MS = 1000;
// Built-in respawn configuration presets
const BUILTIN_RESPAWN_PRESETS = [
{
@@ -1632,6 +1698,7 @@ const SSE_EVENTS = {
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
SESSION_PROMPT_CACHE: 'session:promptCache',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
@@ -1860,7 +1927,10 @@ function escapeHtml(text) {
function formatHistoryBytes(bytes) {
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
if (n < 1024) return 'less than 1 KB';
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
// just under 1 MiB and used to print as "1024 KB".
const kb = Math.round(n / 1024);
if (kb < 1024) return `${kb} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -1874,12 +1944,27 @@ function formatHistoryBytes(bytes) {
* - atCeiling: the FULL capture itself hit the byte ceiling
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
* and the button only ever ended in the downgrade refusal. Nothing to offer,
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
const historyLines =
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
? Math.max(0, state.paneHistoryLines)
: null;
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
const retained = Math.max(0, state.retainedBytes || 0);
const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1902,9 +1987,22 @@ function computeHistoryTruncationNotice(state = {}) {
canLoadMore: false,
};
}
// Name what a pull can actually return when the server said: the byte gap
// counts repaints and redraw bloat, and overstates it many times over.
// Without that count, a `lines=`-bounded capture is cut by tmux before any
// bytes are counted, so its fullSize is the window itself and the remainder's
// size is unknown: say that more exists without inventing an amount. That
// holds even when `tail` also cut bytes off the window (`dropped`), since tmux
// still holds more beyond it (`historyLinesCut`, set by the server).
const remainder =
dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
const more =
historyLines !== null
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
: `${remainder} may still be retained.`;
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
message: `Showing the most recent ${shown} of this session. ${more}`,
canLoadMore: true,
};
}
@@ -1949,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1961,13 +2059,18 @@ function absoluteFilePathPattern() {
* points at goes to the tail/log viewer, which is the right home for a growing
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
*
* Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
* document to read, so the preview renders it (its MD pill gives the source)
* instead of the log viewer streaming it as raw text. Other text (.log, .txt,
* .json, code) keeps the log viewer, which follows a file still being written.
*
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
* (src/attachment-registry.ts, the single source) — they diverged once and an
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus md markdown').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -1976,6 +2079,155 @@ function previewsInFileViewer(filePath) {
return FILE_PREVIEW_EXTENSIONS.has(ext);
}
/**
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
* or a bare `look_montage.png`. Same extension list and body as
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
* equal).
*
* Without a known root to anchor on, the START is what has to be unambiguous:
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
* a URL. The path itself is group 2. Its first character is never `/`: absolute
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
* the file, not the punctuation around it.
*
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* then `~/` and relative ones, then folder paths — each only where it overlaps
* neither an earlier one nor a URL.
*
* The one entry point for both consumers (terminal link provider, response
* viewer), so a path form is clickable in both or in neither.
*
* @param {string} text
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
* `text`; `folder` marks a folder path (folder grid) rather than a file.
*/
function findFilePathLinks(text) {
const value = String(text || '');
const found = [];
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
const absolute = absoluteFilePathPattern();
while ((match = absolute.exec(value)) !== null) {
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
}
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
while ((match = relative.exec(value)) !== null) {
const path = match[2];
const index = match.index + match[1].length;
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index });
}
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
while ((match = folders.exec(value)) !== null) {
const path = folderLinkPath(match[2]);
const index = match.index + match[1].length;
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index, folder: true });
}
return found.sort((a, b) => a.index - b.index);
}
/**
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
* trailing slash (`builds/captures/`). Same boundary in front as the relative
* file pattern (group 1), the candidate itself is group 2 and goes through
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
* File links are found first and win any overlap. A folder path ends at `:`,
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
* the `://` lookahead stops at the next colon instead of rescanning the token from
* every one (quadratic on `a:a:a:…//`).
*
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const FOLDER_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
/**
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
*
* Sentence punctuation after the path is not part of it. A path ending in `/` is
* a folder; otherwise only a rooted or `~/` path whose last segment has no
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
* without a slash at the end is prose, not a folder.
*/
function folderLinkPath(candidate) {
// `$` and `#` too: a shell prompt (`user@host:~/proj$ `, `root@c:/app# `) ends
// its cwd with one. A folder whose name really ends in one links one short.
const path = String(candidate || '').replace(/[.,:;!?'"$#]+$/, '');
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
const last = path.slice(path.lastIndexOf('/') + 1);
return /^\.?[^.]+$/.test(last) ? path : null;
}
/**
* Where a clicked path points, as the preview routes need it.
*
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
* session's HOST, which only the server knows (a remote case's home is on the
* remote machine), so the server expands it. A relative path is resolved
* against the session's working directory, `.` and `..` collapsed, so a
* `../other/x.png` ends up outside the workspace and routes like any other
* external path. Without a working directory it is returned as is.
*
* @param {string} filePath
* @param {string} [workingDir]
* @returns {string}
*/
function resolveLinkedFilePath(filePath, workingDir) {
const path = String(filePath || '');
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
const parts = [];
for (const part of `${workingDir}/${path}`.split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return `/${parts.join('/')}`;
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
* the two equal). The terminal sends a click on one to the preview, never to
* the log viewer, and the preview renders it with a Page pill back to source.
*/
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
function rendersAsPage(filePath) {
const ext = String(filePath || '').split('.').pop().toLowerCase();
return HTML_PAGE_EXTENSIONS.has(ext);
}
/**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -2080,6 +2332,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
return { startRow, endRow, text, offsetToCell, cellToOffset };
}
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
/**
* Whether the line `nextText` carries on a file path `prevText` broke off.
*
* Answers `'join'` when the path ends (reaches a known extension) on the next
* line, `'chain'` when the next line is nothing but more of the path (a path
* spanning three or more lines; only worth keeping once a later line ends it),
* and `null` otherwise. The broken-off token must already look like a path in
* progress: it contains a `/`, ends on a path character and does not yet end
* on an extension. A next line that opens a NEW path is never glued on: a rooted
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
* top directory the broken token did (the next entry of a listing).
*/
function pathContinuation(prevText, nextText) {
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
const next = String(nextText || '').replace(/^\s+/, '');
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
return head === next.replace(/\s+$/, '') ? 'chain' : null;
}
/**
* {@link terminalLogicalLine}, extended across the line breaks a program puts
* INSIDE a file path. Used by the link provider only; touch selection keeps the
* plain logical line.
*
* Claude Code wraps its tool output itself, a few columns short of the terminal
* edge, so neither continuation signal of terminalLogicalLine fires (no
* `isWrapped`, the row does not reach the last column) and a long path was only
* linked up to the break:
*
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
* 026-10-10/ghost1b_gameplay.png err=0 …
*
* A following logical line is glued on (its indent dropped, cell mapping kept
* exact) when {@link pathContinuation} says it carries the path on and the row it
* broke off is at least as long as the row it continues on. Bounded by the same
* `maxRows` as the logical line.
*
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
*/
function terminalPathLine(buffer, row, cols, maxRows) {
const base = terminalLogicalLine(buffer, row, cols, maxRows);
if (!base) return null;
const bound = Math.max(1, maxRows || 12);
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
const rowsOf = (line) => line.endRow - line.startRow + 1;
// A program breaking a long token fills the row to its wrap width, and no
// later row of that block is wider. So the row a path broke off is at least as
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
// to a longer ` file.png written` below it.
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
const continues = (prev, next, prevText) =>
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
const extend = (first) => {
const parts = [first];
let text = first.text;
let rows = rowsOf(first);
let confirmed = 1;
for (;;) {
const next = lineAt(parts[parts.length - 1].endRow + 1);
if (!next || rows + rowsOf(next) > bound) break;
const kind = continues(parts[parts.length - 1], next, text);
if (!kind) break;
parts.push(next);
text += next.text.replace(/^\s+/, '');
rows += rowsOf(next);
if (kind === 'join') confirmed = parts.length;
}
return parts.slice(0, confirmed);
};
// The hovered row may be the second or third line of a broken path: walk back
// to where it could start, then let the forward pass decide what really joins.
let first = base;
let rows = rowsOf(base);
while (first.startRow > 0) {
const prev = lineAt(first.startRow - 1);
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
first = prev;
rows += rowsOf(prev);
}
let parts = extend(first);
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
if (parts.length === 1) return parts[0];
const segments = [];
let text = '';
for (const [i, part] of parts.entries()) {
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
segments.push({ part, textStart: text.length, skip });
text += part.text.slice(skip);
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
}
return segments[0].part.offsetToCell(offset);
};
const cellToOffset = (targetRow, targetCol) => {
for (const seg of segments) {
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
const offset = seg.part.cellToOffset(targetRow, targetCol);
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
}
return -1;
};
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
@@ -2992,8 +3372,16 @@ function describeSessionHarness(session, catalog) {
if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
window.CodemanFilePaths = {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
+112 -6
View File
@@ -4448,6 +4448,7 @@ var GestureController = class {
// packages/gesture-control/src/codeman/entry.ts
var TAB_SELECTOR = ".session-tab";
var TILE_SELECTOR = "#tileGrid .tile";
var PANEL_SELECTOR = ".cg-float";
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
var DOCK_SELECTOR = ".session-tabs";
@@ -4457,6 +4458,8 @@ var FLOAT_W = 640;
var FLOAT_H = 420;
var DETACH_PULL_PX = 70;
var TAP_CANCEL_PX = 45;
var TILE_MOVE_PX = 40;
var TILE_GHOST_W = 280;
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
var GestureBridge = class {
constructor() {
@@ -4533,7 +4536,7 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on \u2014 pinch a tab, window, or button";
this.status.textContent = "on: pinch a tab, tile, window, or button";
} catch (err) {
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
@@ -4588,6 +4591,30 @@ var GestureBridge = class {
this.status.textContent = "moving window";
return;
}
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add("tile--dragging");
this.grabs.set(hand, {
kind: "tile",
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
ox: x2,
oy: y2,
armed: false,
target: null,
out: false
});
this.positionGhost(ghost, x2, y2);
this.status.textContent = "moving tile";
return;
}
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
const id = tab?.dataset.id;
if (tab && id) {
@@ -4599,7 +4626,7 @@ var GestureBridge = class {
ghost.style.height = `${rect.height}px`;
document.body.append(ghost);
tab.classList.add("cg-grabbed");
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
this.positionGhost(ghost, x2, y2);
return;
}
@@ -4613,13 +4640,30 @@ var GestureBridge = class {
}
onDrag(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.positionGhost(grab.ghost, x2, y2);
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
grab.out = out;
grab.ghost.classList.toggle("cg-armed", !!target);
grab.ghost.classList.toggle("cg-out", out);
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
return;
}
if (grab?.kind === "tab") {
this.positionGhost(grab.ghost, x2, y2);
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
if (pulled !== grab.armed) {
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
grab.armed = pulled;
grab.ghost.classList.toggle("cg-armed", pulled);
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
}
return;
}
@@ -4641,16 +4685,40 @@ var GestureBridge = class {
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove("cg-tap-armed");
this.taps.delete(hand);
this.status.textContent = "on \u2014 pinch a tab, window, or button";
this.status.textContent = "on: pinch a tab, tile, window, or button";
}
}
onDrop(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash("cancelled");
return;
}
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
} else {
this.flash("cancelled");
}
return;
}
if (grab?.kind === "tab") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
if (grab.armed) this.floatPanel(grab.id, x2, y2);
this.setTileTarget(grab, null);
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
else this.flash("cancelled");
return;
}
@@ -4775,11 +4843,41 @@ var GestureBridge = class {
ghost.style.left = `${x2}px`;
ghost.style.top = `${y2}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
tileGhost(tileEl, rect) {
const ghost = el("div", "cg-ghost cg-tile-ghost");
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector(".tile-header");
if (header) {
const copy = header.cloneNode(true);
copy.removeAttribute("draggable");
ghost.append(copy);
}
ghost.append(el("div", "cg-tile-ghost-body"));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
setTileTarget(grab, target) {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove("tile--drop-target");
target?.el.classList.add("tile--drop-target");
}
grab.target = target;
}
cancelAllGrabs() {
for (const grab of this.grabs.values()) {
if (grab.kind === "tab") {
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
this.setTileTarget(grab, null);
} else if (grab.kind === "tile") {
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
} else if (grab.kind === "panel") {
grab.panel.el.style.pointerEvents = "";
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
@@ -4791,6 +4889,7 @@ var GestureBridge = class {
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
this.taps.clear();
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
}
onStatus(fps, hands) {
const { width, height } = this.canvas;
@@ -4863,6 +4962,13 @@ function injectStyles() {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+46 -5
View File
@@ -93,6 +93,16 @@
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'New session': '新建会话',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
'Parent loop; placed on its own': '父会话形成循环;单独放置',
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
Focus: '焦点',
'Add to Focus': '添加到焦点',
'Remove from Focus': '从焦点移除',
'Focus actions': '焦点操作',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
@@ -139,6 +149,8 @@
Drag: '拖动',
"a tile's header": '窗格的标题栏',
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
"a tile's header out of the window": '窗格的标题栏到窗口之外',
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
@@ -156,8 +168,9 @@
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
'Only image files are supported': '仅支持图像文件',
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
// view's drop (image-input.js) uses 'Only image or video files are supported'.
'Unsupported file type': '不支持的文件类型',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
@@ -166,6 +179,11 @@
'Failed to restore terminal size': '恢复终端尺寸失败',
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
'Drag to move the tile': '拖动可移动窗格',
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
// pop-out window's title as a handle back onto the tiles.
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
'Resize tile columns': '调整窗格列宽',
'Resize tile rows': '调整窗格行高',
Attach: '附加',
@@ -214,6 +232,9 @@
'Close window': '关闭窗口',
'Session unavailable': '会话不可用',
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
'Session moved': '会话已移走',
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
// Welcome / quick start / common actions
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
@@ -405,6 +426,9 @@
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
'Detach Tiles': '分离窗格',
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (label over value)" must not read as the grid.
@@ -461,6 +485,9 @@
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
'Auto-name Sessions': '自动命名会话',
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
@@ -663,6 +690,8 @@
// placeholder and label are looked up by the module at build time, since
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'Only image or video files are supported': '仅支持图片或视频文件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
@@ -699,6 +728,16 @@
'Session Color': '会话颜色',
'Working Directory': '工作目录',
'Set working directory': '设置工作目录',
// Session Options > Session > Working folder (session-ui.js openSessionOptions).
'Working folder': '工作目录',
"Where this session's CLI runs, on the machine hosting Codeman.":
'此会话的 CLI 运行所在的目录,位于运行{name}的机器上。',
"Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine.":
'此会话的 CLI 运行所在的目录,位于通过 SSH 连接的远程主机上,而不是本机。',
"On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in.":
'位于运行{name}的机器上,并挂载到此会话 CLI 运行所在的 Docker 容器中。',
'Path copied': '路径已复制',
'Copy failed': '复制失败',
'Resume Conversation': '继续对话',
'Close Session': '关闭会话',
'Choose how to close': '选择关闭方式',
@@ -1215,6 +1254,9 @@
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
[/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`],
[/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`],
[
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
@@ -1280,9 +1322,8 @@
// The same while tiles can move, with the drag hint on a second line.
// Anchored on the hint, so a bare state word is safe here.
[
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
(_m, state, duration) =>
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
],
];
for (const [pattern, replacement] of patterns) {
+185 -15
View File
@@ -14,6 +14,69 @@
Object.assign(CodemanApp.prototype, {
initImageInput() {
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
// sits invisibly over the button, and the browser opens the picker itself.
//
// Nothing here may open the picker from script. While the keyboard is up,
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
// re-dispatches it as a scripted click, and iOS will not open a file picker
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
// milliseconds after pointerdown, no picker, no error, and no real click after
// it either because the gesture was cancelled. A native tap on the input is not
// a button tap, so the shim leaves it alone and the click stays trusted. It also
// anchors the picker popover to the input's own frame (a `hidden` input has
// none, which is why the composer's picker floated loose).
//
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
// so the fixed toolbar never reflows out from under the finger mid-tap.
//
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
// responder to present the picker, and on DEVICE it does not hand the keyboard
// back when the picker closes, even though focus never left the textarea.
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
// not take a keyboard measurement from one.) A blur-then-focus on the close
// of the picker, cancel and change alike, restores DOM focus and still leaves
// the keyboard down, because iOS only raises it inside a user gesture. So the
// user taps the prompt when ready to type, which is the keyboard policy
// everywhere else in the app.
//
// Do not try to hide the keyboard's exit by freezing the page's keyboard
// state across the picker. That was tried (holdKeyboardState, reverted) to
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
// bar strand in the MIDDLE of the screen over a dead black band, which is far
// worse than the reflow. The page must follow the keyboard out.
const attachBtn = document.getElementById('attachBtnMobile');
const attachInput = document.getElementById('attachFileInput');
if (attachBtn && attachInput) {
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
// The button is the ACCESSIBLE control (the input is aria-hidden and not
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
// the input. That click is trusted, so opening the picker from it is allowed.
attachBtn.addEventListener('click', () => attachInput.click());
attachInput.addEventListener('click', (event) => {
if (!this.activeSessionId) {
event.preventDefault(); // refuse before the picker opens, not after a pick
this.showToast('Open a session to attach a file', 'error');
return;
}
// Captured before the picker opens: the session can be deleted while it is
// up, and the pick must not land on whatever replaced it.
this._attachSessionId = this.activeSessionId;
});
attachInput.addEventListener('change', () => {
const files = Array.from(attachInput.files || []);
// Reset so picking the same file again still fires change.
attachInput.value = '';
// Captured when the picker opened (see the click handler above).
const sessionId = this._attachSessionId;
this._attachSessionId = null;
if (files.length > 0) {
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
}
});
}
// Drag-and-drop handlers on terminal container
const container = document.getElementById('terminalContainer');
if (!container) return;
@@ -38,15 +101,100 @@ Object.assign(CodemanApp.prototype, {
if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
this.showToast('Only image files are supported', 'error');
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
if (mediaFiles.length === 0) {
this.showToast('Only image or video files are supported', 'error');
return;
}
this._uploadAndInsertImages(imageFiles);
this._uploadAndInsertImages(mediaFiles);
});
},
/** Upload the toolbar Attach selection and put the paths on the prompt. The
* Compose dialog is deliberately not involved (see initImageInput).
*
* With local echo on (every phone by default) the paths go into the OVERLAY as
* pending text rather than to the PTY. That is what makes them part of "what the
* user typed": they paint at the prompt, anything typed after them appends in
* order, Enter flushes the whole line, and opening Compose adopts path and prose
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
* instead would strand the path on the terminal while Compose took only the prose.
* Without the overlay (local echo off) there is nothing to buffer into, so the
* text goes straight out. The trailing space keeps the next word off the path. */
async _attachFromToolbar(files, sessionId) {
const media = files.filter((f) => this._promptAttachKind(f));
if (media.length === 0) {
this.showToast('Only image or video files are supported', 'error');
return;
}
if (!sessionId) return; // no captured session: refuse rather than guess at one
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
if (paths.length === 0) return;
// Deleted while the upload ran: a write would go to a dead session, and the
// flushed maps below would gain an entry whose cleanup has already run.
if (this.sessions && !this.sessions.has(sessionId)) return;
const text = paths.join(' ');
// The prompt composer, if it opened for this session while the upload ran, is
// where the prompt now lives: it took the terminal's pending text with it, so
// appending behind it would strand the path outside the text being composed.
// _insertComposerText adds its own separator and fires the draft save.
const openComposer = KeyboardAccessoryBar._composerOverlay;
const composerTextarea =
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
? openComposer.querySelector('.prompt-composer-textarea')
: null;
if (composerTextarea) {
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
return;
}
const stillActive = this.activeSessionId === sessionId;
// The overlay holds the prompt only while local echo actually owns it. A
// session handed back to plain PTY echo by a composer nav key (echo
// passthrough) skips the whole overlay branch in onData, Enter included, so
// text buffered there would sit on screen and never be submitted. Nor while
// the tile grid owns the terminal area: the main terminal is parked and
// hidden then, and a tile's Enter never reaches its overlay, so the path
// goes to the PTY like any other background write.
const overlayOwnsPrompt =
stillActive &&
!this._tilesOwnTerminal?.() &&
this._localEchoEnabled &&
this._localEchoOverlay &&
!this._echoPassthroughSessions?.has(sessionId);
if (overlayOwnsPrompt) {
// Prompt text the overlay has not adopted yet (a tab completion, a restored
// tab) only lands in its flushed half once detection runs, and appendText
// runs it AFTER we would have measured. Detect first, or the separator is
// computed against an empty string and the path fuses onto a real word.
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
this._localEchoOverlay.detectBufferText();
}
const flushed = this._localEchoOverlay.getFlushed().text || '';
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
this._localEchoOverlay.appendText(gap + text + ' ');
} else {
// No buffer to measure, so lead with a separator unconditionally rather than
// risk fusing onto whatever the CLI's own composer already holds.
const out = ` ${text} `;
this._sendInputAsync(sessionId, out);
// A background session's prompt is tracked in the flushed maps, which is what
// a later composer open adopts and erases. An untracked write leaves that
// count short, so the erase would eat the tail of this path instead. Not for
// a passthrough session: its cursor can sit mid-text, nothing tracks its
// composer, and Enter there never clears the maps, so a record would offset
// the overlay for the next prompt instead.
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
if (!this._flushedOffsets) this._flushedOffsets = new Map();
if (!this._flushedTexts) this._flushedTexts = new Map();
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
}
}
},
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed).
@@ -82,15 +230,13 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images in clipboard items
// Check for images or videos in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
var blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
var blob = items[i].getAsFile();
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
}
}
@@ -120,6 +266,21 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/** What a pick or a paste may upload: 'image', or 'video' for the camera
* roll's clips (the server streams those to disk under their own cap), else
* null. The one filter behind Attach, drop, the Compose and paste-dialog
* pickers and the clipboard handlers. The MIME type decides; some Android
* file managers hand over a File with none, so the name's extension is the
* fallback, mirroring the server's own allowlists. */
_promptAttachKind(file) {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
return null;
},
// Max images accepted in one batch (paste / drop / mobile picker). Each is
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
// rate limit while covering "select a bunch of photos at once".
@@ -136,6 +297,7 @@ Object.assign(CodemanApp.prototype, {
async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = options.sessionId || this.activeSessionId;
if (!sessionId) return [];
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
let files = Array.from(fileList || []);
if (files.length === 0) return [];
@@ -153,7 +315,7 @@ Object.assign(CodemanApp.prototype, {
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
const results = new Array(total); // preserve selection order for insertion
const progress = () =>
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
progress();
// Bounded-concurrency worker pool over the file list.
@@ -197,11 +359,11 @@ Object.assign(CodemanApp.prototype, {
// Final status: successes, plus any failures / cap so nothing is silent.
const parts = [];
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
return paths;
},
@@ -209,7 +371,10 @@ Object.assign(CodemanApp.prototype, {
const form = new FormData();
form.append('image', file);
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
// A video takes the server's disk-streamed path and its larger cap; the flag
// has to ride the URL because the size budget is fixed before the part is read.
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
method: 'POST',
body: form,
});
@@ -231,9 +396,14 @@ Object.assign(CodemanApp.prototype, {
// decode/encode failure the original file is returned unchanged so the server
// still gets a chance (and logs a precise diagnostic).
async _normalizeImageForUpload(file) {
if (file.type === 'image/gif') return file;
// Only an image goes through the canvas; a video is uploaded as picked. With no
// MIME (some Android file managers) the name's extension stands in, as it does in
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
const name = (file.name || '').toLowerCase();
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
const toPng = file.type === 'image/png';
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
const url = URL.createObjectURL(file);
try {
const img = new Image();
+76
View File
@@ -614,7 +614,11 @@
<div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>&lsaquo;</button>
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</button>
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
@@ -634,6 +638,22 @@
</div>
</div>
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
<div class="folder-grid-overlay" id="folderGridOverlay">
<div class="folder-grid-window">
<div class="file-preview-header">
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
<div class="file-preview-actions">
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">&uarr;</button>
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">&times;</button>
</div>
</div>
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
</div>
</div>
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
<div class="response-viewer" id="responseViewer">
<div class="response-viewer-header">
@@ -757,6 +777,21 @@
</div>
</div>
<!-- Mobile-only case button + gear -->
<!-- Phone-only Attach (wired in image-input.js): the button opens its own file input,
which sits invisibly over the button so iOS anchors the picker popover to it. The
Compose dialog is deliberately not involved: it focuses its textarea, which raises
the keyboard under the picker sheet. Sits BEFORE the mic so the two share order: 5
and source order keeps Attach on the left. -->
<span class="btn-attach-mobile-wrap">
<button type="button" class="btn-attach-mobile" id="attachBtnMobile" title="Attach" aria-label="Attach">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="m21 15-5-5L5 21"/>
</svg>
</button>
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
</span>
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
title="Voice input" aria-label="Start voice input" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -860,6 +895,7 @@
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
<div><kbd>Drag</kbd> a tile's header out of the window</div><div>Open the Tile in Its Own Window (Detach Tiles)</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
@@ -1286,6 +1322,22 @@
<!-- Hidden wholesale for non-claude sessions and for a session with
no live process (session-ui.js: openSessionOptions). -->
<div id="sessionRespawnSection">
<!-- Claude Code (v2.1.251+) reports the main conversation's prompt-cache
state on the statusline; the exporter keeps it per session and this
row shows it live (session-ui.js: updatePromptCacheStatus). -->
<div class="set-group">
<div class="set-group-head"><h4>Prompt cache</h4></div>
<p class="set-group-hint">While the cached conversation is warm, a turn re-reads it at the cheap cached rate. Once it sits idle past the cache lifetime, the next turn re-processes the whole context at the cache-write rate.</p>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Status</span>
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
</div>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Loop control</h4></div>
<div class="set-group-body">
@@ -1407,6 +1459,16 @@
<input type="text" id="modalSessionName" class="set-input" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
</div>
</div>
<div class="set-row set-row-block">
<div class="set-row-text">
<span class="set-row-label">Working folder</span>
<span class="set-row-desc" id="modalSessionPathDesc">Where this session's CLI runs, on the machine hosting Codeman.</span>
</div>
<div class="set-row-actions">
<code id="modalSessionPath" class="set-static mono" style="flex: 1; min-width: 0; word-break: break-all; user-select: all;"></code>
<button type="button" class="btn-toolbar btn-sm" id="modalSessionPathCopy" onclick="app.copySessionPath()">Copy</button>
</div>
</div>
<div class="set-row set-row-block">
<div class="set-row-text">
<span class="set-row-label">Session color</span>
@@ -2233,6 +2295,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
<div class="set-row-text">
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
<span class="set-row-desc">Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTileDetach"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
@@ -2247,6 +2316,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsSpawnedTabsFollowParentItem" data-search="spawned child session follow parent tab group lineage move">
<div class="set-row-text">
<span class="set-row-label">Spawned Tabs Follow Their Parent <span class="set-tag">synced</span></span>
<span class="set-row-desc">A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSpawnedTabsFollowParent"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
<div class="set-row-text">
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
+115 -35
View File
@@ -636,12 +636,13 @@ function applyOneShotCtrl(data) {
return { data, consumed: true };
}
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
// an ACK, which would wedge the durable input queue. So the prompt budget is
// that limit minus the two markers, derived here once so the refusal in
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
// The composer's Send, and the shell Paste dialog's, goes out as ONE
// bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a
// frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB,
// compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable
// input queue. So the budget is that limit minus the two markers, derived here
// once so the refusal in _sendBracketedPaste() and the toast that names the
// maximum cannot drift.
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
const COMPOSER_PASTE_START = '\x1b[200~';
const COMPOSER_PASTE_END = '\x1b[201~';
@@ -664,7 +665,7 @@ const KeyboardAccessoryBar = {
_composerDrafts: new Map(),
_composerUploads: new Map(),
_composerOverlay: null,
// Longest prompt Send accepts: the input frame limit minus both markers.
// Longest prompt or shell paste Send accepts: the input frame limit minus both markers.
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
@@ -1205,23 +1206,35 @@ const KeyboardAccessoryBar = {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
},
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
* its DECSET 2004 mirror after terminal replay, even though the CLI still
* expects bracketed input, so build the byte-identical sequence directly on
* the durable session-bound path. Enter stays a separate delayed write
* because Codex drops keys sharing a PTY read with a bracketed paste. */
/** Deliver one complete prompt as an explicit bracketed paste
* (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror
* after terminal replay, even though the CLI still expects bracketed input,
* so the frame is built directly rather than through terminal.paste().
* Enter stays a separate delayed write because Codex drops keys sharing a
* PTY read with a bracketed paste. */
_sendComposedPrompt(sessionId, text) {
if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false;
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
/** One bracketed-paste write on the durable session-bound path, the same
* bytes a terminal paste produces, so the receiving shell or CLI takes a
* multi-line block as one edit. Never presses Enter: callers decide.
* `label` names the payload in the too-long toast. */
_sendBracketedPaste(sessionId, text, label) {
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
app._predictiveEcho?.clearPredictions();
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
// the single-line input transport and is what terminal.paste() emitted.
const pasteText = text.replace(/\r?\n/g, '\r');
// Paste markers inside the text are dropped first: a clipboard carrying
// `ESC[201~` would end the bracket early and run the rest without Enter.
const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
if (pasteText.length > this._composerMaxLength) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
}
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
@@ -1247,7 +1260,11 @@ const KeyboardAccessoryBar = {
const sessionId = app.activeSessionId;
const pending = this._takePendingLocalEcho(sessionId);
const saved = this._composerDrafts.get(sessionId) || '';
const initial = saved + pending;
// A parked draft and text typed (or attached) on the prompt since are two
// separate pieces of prose; one space keeps the draft's last word off the
// prompt's first, unless either side already supplies the gap.
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
const overlay = document.createElement('div');
@@ -1269,7 +1286,7 @@ const KeyboardAccessoryBar = {
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
</div>
`;
@@ -1285,6 +1302,10 @@ const KeyboardAccessoryBar = {
const fileInput = overlay.querySelector('.paste-file-input');
const imageButton = overlay.querySelector('.paste-image');
const sendButton = overlay.querySelector('.paste-send');
// Disabled alongside Send while an upload runs: this button deletes the draft
// and closes the overlay, and the upload would then land its paths in a FRESH
// draft, which the next open would prepend to the handed-back text.
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
const focusTrap = new FocusTrap(overlay);
textarea.value = initial;
if (initial) this._composerDrafts.set(sessionId, initial);
@@ -1292,6 +1313,7 @@ const KeyboardAccessoryBar = {
const initialUploads = this._composerUploads.get(sessionId) || 0;
imageButton.disabled = initialUploads > 0;
sendButton.disabled = initialUploads > 0;
terminalButton.disabled = initialUploads > 0;
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
const saveDraft = () => {
@@ -1318,7 +1340,7 @@ const KeyboardAccessoryBar = {
close({ preserveDraft: false });
};
const handleImages = async (files) => {
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
saveDraft();
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
@@ -1335,6 +1357,8 @@ const KeyboardAccessoryBar = {
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
};
syncUploadUi();
try {
@@ -1366,9 +1390,8 @@ const KeyboardAccessoryBar = {
const items = event.clipboardData?.items;
if (!items) return;
const images = Array.from(items)
.filter((item) => item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
.filter((file) => file && app._promptAttachKind(file));
if (images.length > 0) {
event.preventDefault();
void handleImages(images);
@@ -1382,7 +1405,55 @@ const KeyboardAccessoryBar = {
});
imageButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
// Hand the text BACK to the terminal prompt so both surfaces hold the same
// prompt, the mirror of the adoption composePrompt() does on open. Only a
// single-line draft can make the trip: the prompt transport is single-line
// and strips embedded newlines, so a multi-line draft would arrive mangled
// and instead stays parked as a draft (the dot on the Compose key shows it).
const text = textarea.value;
if (!text || text.includes('\n')) {
close({ focusTerminal: true });
return;
}
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
// as DOM lines on every repaint, so the transport's larger paste cap is not
// the bound that matters here.
if (text.length > this._composerMaxLength) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return;
}
// Cleared, never kept: the text now lives on the prompt, and a surviving
// draft would be prepended to it the next time the composer opens.
this._composerDrafts.delete(sessionId);
close({ focusTerminal: true, preserveDraft: false });
if (
app._localEchoEnabled &&
app._localEchoOverlay &&
app.activeSessionId === sessionId &&
!app._tilesOwnTerminal?.() &&
!app._echoPassthroughSessions?.has(sessionId)
) {
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
// Passthrough is excluded deliberately: such a session skips the overlay
// branch on Enter, so text parked there would never be submitted. The tile
// grid is excluded for the same reason _attachFromToolbar is: the main
// terminal is parked and hidden then, and a tile's Enter cannot flush its
// overlay, so the text goes to the PTY below instead of stranding there.
app._localEchoOverlay.appendText(text);
} else {
// Write-through echo (codex) or no local echo at all: there is no buffer
// to park text in, because the prompt the user sees IS the CLI's own
// composer on the PTY. Checking for the overlay alone left this branch
// doing nothing on codex, so the button looked dead there. That composer
// may already hold text the open adopted nothing of, so lead with a
// separator rather than fuse onto its last word (harmless when empty).
app._predictiveEcho?.clearPredictions();
app._sendInputAsync(sessionId, ` ${text}`);
}
this._syncComposerDraftIndicator();
});
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
sendButton.addEventListener('click', send);
overlay.addEventListener('click', (event) => {
@@ -1399,7 +1470,7 @@ const KeyboardAccessoryBar = {
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/* → camera / photo library / files), the most reliable
* (accept=image/*,video/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
@@ -1408,19 +1479,22 @@ const KeyboardAccessoryBar = {
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
// The shell this overlay was opened for: a tab switch while it is open must
// not deliver the paste to whatever session is active at Send time.
const sessionId = app.activeSessionId;
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
</div>
`;
@@ -1429,22 +1503,30 @@ const KeyboardAccessoryBar = {
const close = () => overlay.remove();
// Only shell sessions reach this overlay (agent bars get Compose instead),
// so the text lands at the prompt as one bracketed paste and is NOT run:
// sent raw, every newline was an Enter and each line ran on its own as it
// arrived. The user reviews the block and presses Enter themselves. A shell
// that never enabled bracketed paste (dash, bash before 5.1) gets the
// markers stripped by tmux, so there every line but the last still runs.
const sendText = () => {
const text = textarea.value;
close();
if (text) {
app.sendInput(text);
setTimeout(() => app.sendInput('\r'), 80);
if (!text) {
close();
return;
}
// A refused (too-long) paste keeps the dialog and its text for trimming.
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
};
// Filter to images, close the dialog, and hand off to the shared
// Filter to images and videos, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
if (images.length === 0) return false;
close();
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
// Upload to, and insert into, the shell this overlay was opened for.
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images, { sessionId });
return true;
};
@@ -1458,10 +1540,8 @@ const KeyboardAccessoryBar = {
if (!items) return;
const imageFiles = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
const blob = items[i].getAsFile();
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
}
if (imageFiles.length > 0) {
e.preventDefault();
+11 -2
View File
@@ -794,7 +794,7 @@ Object.assign(CodemanApp.prototype, {
const wrap = document.createElement('div');
wrap.className = 'mobile-overview-row-wrap';
wrap.appendChild(item);
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval, row));
return wrap;
}
@@ -949,7 +949,7 @@ Object.assign(CodemanApp.prototype, {
},
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
_buildMobileOverviewApprovalStrip(approval) {
_buildMobileOverviewApprovalStrip(approval, row) {
const strip = document.createElement('div');
strip.className = 'mobile-overview-approval-strip';
strip.setAttribute('data-i18n-skip', '');
@@ -964,6 +964,15 @@ Object.assign(CodemanApp.prototype, {
});
strip.appendChild(btn);
};
// Alert-only (DeepSeek Harness / supervised hooks): open the session instead
// of Approve/Deny that the answer route would refuse.
if (approval.answerInTerminal) {
addBtn('Answer in terminal', 'primary', () => {
if (typeof this.selectSession === 'function') this.selectSession(approval.sessionId);
else if (typeof this.openApprovalSession === 'function') this.openApprovalSession(approval.id);
});
return strip;
}
if (approval.options && approval.options.length) {
for (const o of approval.options) {
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
+50 -6
View File
@@ -614,7 +614,9 @@ html.mobile-init .file-browser-panel {
/* Mobile voice input button in toolbar — initially hidden via inline style,
shown by VoiceInput._showButtons() clearing the inline display */
.btn-voice-mobile {
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
.btn-voice-mobile,
.btn-attach-mobile {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -626,15 +628,48 @@ html.mobile-init .file-browser-panel {
color: #93c5fd;
cursor: pointer;
flex-shrink: 0;
order: 5; /* Between Run Shell and settings gear */
}
.btn-voice-mobile svg {
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
equal order source order keeps Attach on the left of the mic. */
.btn-voice-mobile,
.btn-attach-mobile-wrap {
order: 5;
}
/* Relative box for the invisible file input that sits over the button and TAKES
THE TAPS (see initImageInput): a native tap on the input is the only thing that
opens the picker while the keyboard is up, and iOS anchors the picker popover to
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
.btn-attach-mobile-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
overflow: hidden;
}
.btn-attach-mobile-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
}
.btn-voice-mobile svg,
.btn-attach-mobile svg {
width: 13px;
height: 13px;
}
.btn-voice-mobile:active {
.btn-voice-mobile:active,
.btn-attach-mobile:active,
/* The input above the button swallows the press, so the wrapper is what goes
:active. Without this the only phone button with no press feedback is Attach. */
.btn-attach-mobile-wrap:active .btn-attach-mobile {
background: #2a3a5f;
}
@@ -3245,13 +3280,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
both stylesheets and repainted the armed modifier back to a resting button on
all four light skins. Excluding the state here fixes phone and tablet at once;
adding a class to the armed rules would only have moved the tie. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
background: var(--control-bg);
border-color: var(--control-border);
color: var(--text-dim);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
}
/* Attach's press feedback comes from its wrapper (the file input over the button
takes the press). Kept out of the :is() list above on purpose: a descendant
selector there would raise that list's specificity for every button in it. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
+354 -11
View File
@@ -30,10 +30,11 @@ const MARKDOWN_EXTS = new Set(['md', 'markdown']);
// would be dropped on the next save).
const FILE_PREVIEW_PREF_KEYS = {
mdRendered: 'codeman:filePreviewMdRendered',
htmlRendered: 'codeman:filePreviewHtmlRendered',
lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap',
};
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -4037,6 +4038,9 @@ Object.assign(CodemanApp.prototype, {
* unknown workingDir answers false, leaving the historical path untouched.
*/
_isExternalPreviewPath(filePath, sessionId) {
// `~/…` (a terminal or response-viewer link): only the server knows the home
// of the session's host, and only the attachment route expands it.
if (typeof filePath === 'string' && filePath.startsWith('~/')) return true;
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
const workingDir = this.sessions.get(sessionId)?.workingDir;
if (!workingDir) return false;
@@ -4083,8 +4087,19 @@ Object.assign(CodemanApp.prototype, {
}
},
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
/**
* @param {object} [options]
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
* granted folder (folder grid): rendered from the folder's by-name routes, so
* paging through 300 renders never touches the attachment registry.
*/
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
if (!sessionId || !filePath) return;
const folderFile = options.folderFile || null;
// Prev/next only while browsing a folder grid; a preview opened from anywhere
// else ends that browsing.
if (!folderFile && this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(folderFile ? null : this._previewFolderPath(filePath, sessionId, attachmentId));
const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle');
@@ -4093,6 +4108,12 @@ Object.assign(CodemanApp.prototype, {
if (!overlay || !bodyEl) return;
// Only the newest load may write: paging ←/→ through a folder starts the next
// load while the previous one still awaits its fetch, and its late answer would
// put the previous file's text under the next file's title.
const load = (this._filePreviewLoad = (this._filePreviewLoad || 0) + 1);
const stale = () => load !== this._filePreviewLoad;
// Edit mode: reset any prior editor state whenever a preview (re)loads.
this._resetFilePreviewEdit();
// Stop whatever the previous preview was playing. Overwriting innerHTML
@@ -4106,6 +4127,8 @@ Object.assign(CodemanApp.prototype, {
// Same for the text-view toggles: they act on the text this load has not
// fetched yet, and an image or PDF has nothing for them to toggle.
this.filePreviewText = null;
this.filePreviewPage = null;
this.filePreviewPageRefused = false;
this._updateFilePreviewToolbar('none');
// Show overlay with loading state
@@ -4116,14 +4139,36 @@ Object.assign(CodemanApp.prototype, {
const ext = (filePath.split('.').pop() || '').toLowerCase();
// HTML renders as a page: the server mints a sandboxed /html-view URL for the
// file's directory (html-view-routes.ts), so relative CSS, scripts and images
// resolve, and the page runs in an opaque origin that cannot reach Codeman.
// The Page pill switches to the source view below (with its Edit button), and
// a refused mint (remote case, broad folder, hidden directory) falls through
// to that source view on its own.
if (!attachmentId && rendersAsPage(filePath) && this._filePreviewPref('htmlRendered')) {
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
if (pageUrl) {
footerEl.textContent = 'HTML';
this.filePreviewPage = { filePath, sessionId };
this.filePreviewDetachUrl = pageUrl;
if (detachBtn) detachBtn.hidden = false;
bodyEl.innerHTML = `<iframe src="${escapeHtml(pageUrl)}" title="${escapeHtml(filePath)}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"></iframe>`;
this._updateFilePreviewToolbar('page');
return;
}
// The pill would only retry a page the server just refused.
this.filePreviewPageRefused = true;
}
// Out-of-workspace path: mint an attachment id up front. Every branch below
// talks to a workspace-confined route, so without this the image/PDF ones
// render a broken frame and the text one reports a bare "File not found"
// for a file that is sitting right there on disk.
let externalError = '';
let externalSize = 0;
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
const external = await this._registerExternalPreview(filePath, sessionId);
if (stale()) return;
attachmentId = external.attachmentId || null;
externalError = external.error || '';
externalSize = external.size || 0;
@@ -4140,9 +4185,14 @@ Object.assign(CodemanApp.prototype, {
// (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx';
const byIdBase = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`
: folderFile
? `/api/sessions/${sessionId}/folders/${encodeURIComponent(folderFile.folderId)}/files/${encodeURIComponent(folderFile.name)}`
: null;
this.filePreviewDetachUrl = CodemanBase.url(
attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
byIdBase
? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
@@ -4152,8 +4202,9 @@ Object.assign(CodemanApp.prototype, {
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
// A folder-grid file renders the same way, from its folder's by-name routes.
if (byIdBase) {
const base = CodemanBase.url(byIdBase);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import
@@ -4163,6 +4214,7 @@ Object.assign(CodemanApp.prototype, {
// Size when we just registered the file ourselves, so a path opened from a
// link reads like a workspace preview instead of a bare "PNG". History
// cards arrive with an id and no size and keep the short form.
if (folderFile && !externalSize) externalSize = options.size || 0;
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
@@ -4190,6 +4242,7 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
if (stale()) return;
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
const lines = text.split('\n');
// Markdown keeps every line the Range read returned: the byte bound is
@@ -4201,13 +4254,14 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = shown;
// attachmentId: a card's filePath is the bare file name, so the
// rebase pass must know there is no directory to resolve against.
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
this.filePreviewText = { ext, sessionId, filePath, attachmentId: attachmentId || folderFile?.folderId };
this._renderFilePreviewText();
if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
footerEl.textContent = `${footerEl.textContent} (${note})`;
}
} catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
}
@@ -4240,11 +4294,14 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed to load image');
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
const svgText = await res.text();
if (stale()) return;
const blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
const img = bodyEl.querySelector('img');
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
} catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
return;
@@ -4260,6 +4317,7 @@ Object.assign(CodemanApp.prototype, {
if (!res.ok) throw new Error('Failed to load file');
const result = await res.json();
if (stale()) return;
if (!result.success) throw new Error(result.error || 'Failed to load file');
const data = result.data;
@@ -4299,14 +4357,266 @@ Object.assign(CodemanApp.prototype, {
}
}
} catch (err) {
if (stale()) return;
console.error('Failed to preview file:', err);
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
},
// ===== Folder grid =====
/**
* Show every previewable file of a folder as a grid (absolute or `~/` path on the
* session's host). The server grants the folder an id (`POST …/folders`), and the
* grid and the previews it opens fetch files by `folderId + name` only.
*
* Opened from a clicked folder path: when the server finds a FILE there after all,
* it falls back to the file preview, i.e. what a click did before folders were links.
*/
async openFolderGrid(folderPath, sessionId = this.activeSessionId) {
if (!sessionId || !folderPath) return;
const overlay = this.$('folderGridOverlay');
const body = this.$('folderGridBody');
if (!overlay || !body) return;
this._bindFolderGridKeys();
// Two quick opens (a chip, then its parent): only the newer one may render.
const load = (this._folderGridLoad = (this._folderGridLoad || 0) + 1);
let result = null;
let status = 0;
try {
const res = await fetch(`/api/sessions/${sessionId}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath }),
});
status = res.status;
result = await res.json().catch(() => null);
} catch (err) {
if (load !== this._folderGridLoad) return;
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
return;
}
if (load !== this._folderGridLoad) return;
if (!result?.success || !result.data) {
const reason = result?.error || `HTTP ${status}`;
if (status === 400 && /not a folder/i.test(reason)) {
this.openFilePreview(folderPath, sessionId);
return;
}
this.showToast(`Cannot open folder ${folderPath}: ${reason}`, 'error');
return;
}
this.folderGrid = { sessionId, ...result.data, index: null };
this._renderFolderGrid();
overlay.classList.add('visible');
body.scrollTop = 0;
},
closeFolderGrid() {
// A listing still on its way must not reopen the grid that was just closed.
this._folderGridLoad = (this._folderGridLoad || 0) + 1;
this.$('folderGridOverlay')?.classList.remove('visible');
const body = this.$('folderGridBody');
// Drop the tiles: lazy <img>s still loading would keep fetching otherwise.
if (body) body.innerHTML = '';
this.folderGrid = null;
this._updateFolderPreviewNav(null);
},
folderGridUp() {
const grid = this.folderGrid;
if (!grid || grid.path === '/') return;
this.openFolderGrid(grid.path.slice(0, grid.path.lastIndexOf('/')) || '/', grid.sessionId);
},
_renderFolderGrid() {
const grid = this.folderGrid;
if (!grid) return;
const title = this.$('folderGridTitle');
const foldersEl = this.$('folderGridFolders');
const body = this.$('folderGridBody');
const footer = this.$('folderGridFooter');
title.textContent = grid.path;
title.title = grid.path;
const upBtn = this.$('folderGridUpBtn');
if (upBtn) upBtn.disabled = grid.path === '/';
foldersEl.innerHTML = '';
for (const name of grid.folders) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'folder-grid-chip';
chip.textContent = `${name}/`;
chip.onclick = () => this.openFolderGrid(`${grid.path === '/' ? '' : grid.path}/${name}`, grid.sessionId);
foldersEl.appendChild(chip);
}
body.innerHTML = '';
if (grid.files.length === 0) {
const empty = document.createElement('div');
empty.className = 'folder-grid-empty';
empty.textContent = 'No previewable files in this folder.';
body.appendChild(empty);
}
grid.files.forEach((file, index) => {
const tile = document.createElement('button');
tile.type = 'button';
tile.className = 'folder-grid-tile';
tile.title = `${file.name} • ${this.formatFileSize(file.size)}`;
tile.dataset.index = String(index);
const thumb = document.createElement('div');
thumb.className = 'folder-grid-thumb';
const label = file.extension.toUpperCase();
const route = this._folderFileUrl(grid, file.name, '');
// Images load themselves (lazily — a folder can hold hundreds of renders);
// PDF/Office show their first page where the server can render one (local
// folders), everything else a type label.
const src =
file.attachmentType === 'image'
? `${route}/raw`
: ['pdf', 'docx', 'pptx'].includes(file.extension)
? `${route}/thumbnail`
: null;
if (src) {
const img = document.createElement('img');
img.loading = 'lazy';
img.decoding = 'async';
img.alt = file.name;
img.src = src;
img.onerror = () => {
thumb.textContent = label;
};
thumb.appendChild(img);
} else {
thumb.textContent = label;
}
const name = document.createElement('span');
name.className = 'folder-grid-name';
name.textContent = file.name;
tile.append(thumb, name);
tile.onclick = () => this._openFolderGridFile(index);
body.appendChild(tile);
});
const count = `${grid.files.length} file${grid.files.length === 1 ? '' : 's'}`;
const subfolders = grid.folders.length ? ` • ${grid.folders.length} folder${grid.folders.length === 1 ? '' : 's'}` : '';
footer.textContent = `${count}${subfolders}${grid.truncated ? ' (folder too large — showing the first entries)' : ''}`;
},
_folderFileUrl(grid, name, suffix) {
return CodemanBase.url(
`/api/sessions/${grid.sessionId}/folders/${encodeURIComponent(grid.folderId)}/files/${encodeURIComponent(name)}${suffix}`
);
},
_openFolderGridFile(index) {
const grid = this.folderGrid;
const file = grid?.files[index];
if (!file) return;
const body = this.$('folderGridBody');
body?.querySelectorAll('.folder-grid-tile.current').forEach((t) => t.classList.remove('current'));
const tile = body?.querySelector(`.folder-grid-tile[data-index="${index}"]`);
tile?.classList.add('current');
tile?.scrollIntoView({ block: 'nearest' });
this.openFilePreview(`${grid.path === '/' ? '' : grid.path}/${file.name}`, grid.sessionId, null, {
folderFile: { folderId: grid.folderId, name: file.name },
size: file.size,
});
// After openFilePreview: it resets the index for previews from elsewhere.
grid.index = index;
grid.lastIndex = index;
this._updateFolderPreviewNav(null);
},
/** Page through the open folder from its preview (‹ › buttons, ←/→). */
stepFolderPreview(delta) {
const grid = this.folderGrid;
if (!grid || grid.index === null || grid.files.length === 0) return;
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
const next = (grid.index + delta + grid.files.length) % grid.files.length;
this._openFolderGridFile(next);
},
/** "Show folder" in the preview: the grid of the previewed file's folder. */
openFolderOfPreview() {
const target = this.filePreviewFolderTarget;
if (!target) return;
this.closeFilePreview();
this.openFolderGrid(target.dir, target.sessionId);
},
/**
* The folder a previewed file lives in, or null when that is unknown — an
* attachment card carries only a bare file name.
*/
_previewFolderPath(filePath, sessionId, attachmentId) {
if (typeof filePath !== 'string' || (attachmentId && !filePath.includes('/'))) return null;
const workingDir = this.sessions?.get?.(sessionId)?.workingDir;
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(filePath, workingDir) : filePath;
if (!resolved.startsWith('/') && !resolved.startsWith('~/')) return null;
const cut = resolved.lastIndexOf('/');
return { dir: resolved.slice(0, cut) || '/', sessionId };
},
/** Show prev/next while browsing a folder grid, else "Show folder" when the file's folder is known. */
_updateFolderPreviewNav(folderTarget) {
const browsing = !!this.folderGrid && this.folderGrid.index !== null && this.folderGrid.files.length > 1;
const prev = this.$('filePreviewPrevBtn');
const next = this.$('filePreviewNextBtn');
if (prev) prev.hidden = !browsing;
if (next) next.hidden = !browsing;
if (folderTarget !== undefined) this.filePreviewFolderTarget = folderTarget;
const inGrid = !!this.folderGrid && this.folderGrid.index !== null;
const folderBtn = this.$('filePreviewFolderBtn');
if (folderBtn) folderBtn.hidden = inGrid || !this.filePreviewFolderTarget;
},
/**
* ←/→ page the folder preview, Escape steps back (preview → grid → closed).
* Capture phase + stopPropagation: the terminal keeps keyboard focus under the
* overlays, and an arrow key that reached xterm would be typed into the session.
*/
_bindFolderGridKeys() {
if (this._folderGridKeysBound) return;
this._folderGridKeysBound = true;
document.addEventListener(
'keydown',
(ev) => {
const grid = this.folderGrid;
if (!grid || ev.altKey || ev.ctrlKey || ev.metaKey) return;
const previewOpen = this.$('filePreviewOverlay')?.classList.contains('visible');
const gridOpen = this.$('folderGridOverlay')?.classList.contains('visible');
if (!gridOpen) return;
const t = ev.target;
const typing =
t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')));
if (typing || this.filePreviewEdit) return;
let handled = true;
if (previewOpen && grid.index !== null && ev.key === 'ArrowLeft') this.stepFolderPreview(-1);
else if (previewOpen && grid.index !== null && ev.key === 'ArrowRight') this.stepFolderPreview(1);
// Grid alone: an arrow opens the last viewed tile (or the first), so it
// pages the folder instead of landing in the terminal behind the grid.
else if (!previewOpen && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && grid.files.length)
this._openFolderGridFile(grid.lastIndex ?? 0);
else if (ev.key === 'Escape') {
if (previewOpen) this.closeFilePreview();
else this.closeFolderGrid();
} else handled = false;
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
},
true
);
},
closeFilePreview() {
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
this._resetFilePreviewEdit();
// Back on the grid (if one is open underneath): its current tile stays marked.
if (this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(null);
const overlay = this.$('filePreviewOverlay');
if (overlay) {
overlay.classList.remove('visible');
@@ -4571,8 +4881,9 @@ Object.assign(CodemanApp.prototype, {
/**
* Show the toggles that apply to the current view: MD for a markdown file in
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
* image, media, PDF, edit mode) hides all three.
* either view, Page for an HTML file in either view (`'page'` = rendered),
* Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
* PDF, edit mode) hides them all.
*/
_updateFilePreviewToolbar(view) {
const set = (id, shown, pressed) => {
@@ -4583,10 +4894,42 @@ Object.assign(CodemanApp.prototype, {
};
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
// Page: shown on a rendered HTML page, and on its source view when that view
// can be re-rendered (a bare attachment-card name has no path to mint from).
const text = this.filePreviewText;
const htmlSource =
view === 'text' &&
!this.filePreviewPageRefused &&
rendersAsPage(text?.filePath) &&
(!text.attachmentId || text.filePath.startsWith('/'));
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
},
/** Mint the sandboxed page URL for an HTML file; null when the server refuses. */
async _mintHtmlViewUrl(filePath, sessionId) {
try {
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath }),
});
const result = await res.json().catch(() => ({}));
return res.ok && result.success ? CodemanBase.url(result.data.url) : null;
} catch {
return null;
}
},
/** Page pill: flip between the rendered page and the source view of an HTML file. */
toggleFilePreviewPage() {
const target = this.filePreviewPage || this.filePreviewText;
if (!target) return;
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
return this.openFilePreview(target.filePath, target.sessionId);
},
toggleFilePreviewMd() {
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
this._renderFilePreviewText();
+78 -22
View File
@@ -621,9 +621,12 @@ Object.assign(CodemanApp.prototype, {
/** Ensure a newly-created session is visible without waiting for the SSE event.
* The POST response and session:created can arrive in either order, so the
* normal idempotent SSE handler remains the single state-upsert path. */
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
* normal idempotent SSE handler remains the single state-upsert path.
* `tabLayout` is the layout a grouped creation returns (see run()'s
* `tabGroupId`): adopting it first draws the new tab inside its group. */
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) {
if (!sessionId) return;
if (tabLayout) this._applyTabLayout?.(tabLayout);
let session = sessionSnapshot;
if (!session && !this.sessions?.has(sessionId)) {
@@ -643,8 +646,13 @@ Object.assign(CodemanApp.prototype, {
this._renderSessionTabsImmediate?.();
},
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
async run() {
/**
* Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity).
* `options` comes from the tab group menu's "New session": `count` overrides
* the instance steppers for this launch only, `tabGroupId` rides every create
* request so the server places the session in that group.
*/
async run(options = {}) {
if (this._runInFlight) return;
const startedAt = Date.now();
@@ -659,12 +667,12 @@ Object.assign(CodemanApp.prototype, {
try {
const mode = this._runMode || 'claude';
if (mode === 'shell') {
return await this.runShell();
return await this.runShell(options);
}
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
return await this.runClaude();
return await this.runClaude(options);
}
return await this._runCliMode(mode);
return await this._runCliMode(mode, options);
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
@@ -1910,9 +1918,10 @@ Object.assign(CodemanApp.prototype, {
}
},
async runClaude() {
async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = this._readTabCount();
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
@@ -1972,6 +1981,7 @@ Object.assign(CodemanApp.prototype, {
for (let i = 0; i < tabCount; i++) {
const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...group,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
});
const doQuickStart = async () => {
@@ -2000,7 +2010,7 @@ Object.assign(CodemanApp.prototype, {
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
remoteIds.push(data.data.sessionId);
}
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
@@ -2046,6 +2056,7 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir, name,
...group,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
@@ -2059,7 +2070,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
sessionIds.push(result.data.session.id);
}
firstSessionId = sessionIds[0];
@@ -2128,11 +2139,12 @@ Object.assign(CodemanApp.prototype, {
}
},
async runShell() {
async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount();
const shellCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
@@ -2178,11 +2190,11 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}`, ...group })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
remoteIds.push(data.data.sessionId);
}
if (remoteIds[0]) {
@@ -2208,7 +2220,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name, ...group })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
@@ -2216,7 +2228,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
sessionIds.push(result.data.session.id);
}
@@ -2257,8 +2269,8 @@ Object.assign(CodemanApp.prototype, {
* their try block, to put the count in the opening banner: `#tabCount` ships
* unconditionally today, but a throw here would escape the launch-error path.
*/
_readTabCount() {
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1));
_readTabCount(override) {
return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1)));
},
/**
@@ -2291,7 +2303,7 @@ Object.assign(CodemanApp.prototype, {
);
if (!data.success) throw new Error(data.error || `Failed to start ${label}`);
customModelAnswered = true;
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
if (!firstSessionId) firstSessionId = data.data.sessionId;
}
if (tabCount > 1) {
@@ -2309,7 +2321,7 @@ Object.assign(CodemanApp.prototype, {
* menu call them directly by name (`app.runOpenCode()` etc.), and several
* tests assert on that name directly too.
*/
async _runCliMode(mode) {
async _runCliMode(mode, options = {}) {
const catalogEntry = registryCliById(mode);
const entry = RUN_MODE_LAUNCH[mode] ||
(catalogEntry && {
@@ -2326,7 +2338,7 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
);
@@ -2364,6 +2376,7 @@ Object.assign(CodemanApp.prototype, {
caseName,
mode,
sessionName,
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}),
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
@@ -2473,6 +2486,14 @@ Object.assign(CodemanApp.prototype, {
if (tab) tab.classList.toggle('tab-show-detach', !!on);
},
/** Copy the open session's working folder (Session Options > Session > Working folder). */
async copySessionPath() {
const session = this.sessions.get(this.editingSessionId);
if (!session?.workingDir) return;
const ok = await this._copyText(session.workingDir);
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
},
openSessionOptions(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
@@ -2534,6 +2555,7 @@ Object.assign(CodemanApp.prototype, {
// Populate auto-resume on usage limit (token pause control)
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
this.updatePromptCacheStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -2552,6 +2574,19 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
}
// Working folder: the path the CLI was launched in on the Codeman host
const _pathEl = document.getElementById('modalSessionPath');
_pathEl.textContent = session.workingDir || '';
_pathEl.title = session.workingDir || '';
document.getElementById('modalSessionPathCopy').disabled = !session.workingDir;
// A remote session's workingDir is the REMOTE path; a docker session's is the
// host bind-mount source the container sees.
document.getElementById('modalSessionPathDesc').textContent = session.remote
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
: session.docker
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
: "Where this session's CLI runs, on the machine hosting Codeman.";
// Initialize color picker with current session color
const currentColor = session.color || 'default';
const colorPicker = document.getElementById('sessionColorPicker');
@@ -2784,6 +2819,27 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Session Options → Respawn → "Prompt cache": the main conversation's cache state as
* Claude Code reports it on the statusline (`session.promptCache`, v2.1.251+), worded by
* the pure `CodemanPromptCache.format` (constants.js). Refreshed on open and on the
* `session:promptCache` SSE event while the modal shows that session.
*/
updatePromptCacheStatus(sessionId) {
const el = document.getElementById('promptCacheStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter);
},
_onSessionPromptCache(data) {
const { sessionId, ...promptCache } = data;
const session = this.sessions.get(sessionId);
if (!session) return;
session.promptCache = promptCache;
this.updatePromptCacheStatus(sessionId);
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
+15
View File
@@ -442,6 +442,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
// Spawned tabs follow their parent: synced (the server places the tab), default OFF.
document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true;
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
@@ -510,6 +512,11 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowTabDetachButton').checked =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
const tileDetachItem = document.getElementById('appSettingsTileDetachItem');
if (tileDetachItem) tileDetachItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
@@ -2582,6 +2589,7 @@ Object.assign(CodemanApp.prototype, {
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
@@ -2616,6 +2624,7 @@ Object.assign(CodemanApp.prototype, {
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
document.getElementById('appSettingsSessionSidebarFontSize').value
@@ -2867,6 +2876,9 @@ Object.assign(CodemanApp.prototype, {
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
showTabDetachButton: _tdb,
// Detach Tiles: what a tile drag does is a property of this device's
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
tileDetachEnabled: _tde,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
// Desktop-only tab decoration, per-device, and likewise absent from the
@@ -3902,6 +3914,8 @@ Object.assign(CodemanApp.prototype, {
// Tiles button: same gate and backstop as Split (tile-grid.js).
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
this._applyTileGridButtonVisibility?.(showTileGridButton);
// Detach Tiles changes what a tile's header does (and says): repaint the handles.
this._renderTileChrome?.();
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
@@ -4358,6 +4372,7 @@ Object.assign(CodemanApp.prototype, {
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
@@ -171,6 +171,17 @@ async function loadWorkbook(bytes) {
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
// than silently expanding again.
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
// ExcelJS follows a worksheet's comment, VML, table and drawing links
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
// once on the parsed model; repair the model first (core.repairExcelJsModel).
// Throws if a future ExcelJS renames reconcile, rather than failing again.
const reconcile = nextWorkbook.xlsx.reconcile;
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
core.repairExcelJsModel(model);
return reconcile.call(this, model, options);
};
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows
// validations, so they are not parsed at all. `maxRows` is a per-sheet
+1 -1
View File
@@ -20,7 +20,7 @@
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
const SPREADSHEET_ASSET_VERSION = 'fd81c01bef7b';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
+110
View File
@@ -472,6 +472,115 @@
return zipApi.zipSync(admission.entries, { level: 0 });
}
// ExcelJS (4.4.0, lib/xlsx/xlsx.js + xform/sheet/worksheet-xform.js `reconcile`)
// links a worksheet to its comments, comment notes (VML), tables and drawing by
// looking the relationship Target up VERBATIM in maps keyed by its OWN spelling
// (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`,
// `drawing1`), then dereferences the result unchecked. openpyxl, which most
// agents write spreadsheets with, uses absolute Targets, its own part names
// (`/xl/comments/comment1.xml`) and a drawing XML ExcelJS parses to nothing, so
// a workbook with ONE comment, table or chart threw ("reading 'comments'",
// "'name'", "'anchors'") before a single cell was shown.
//
// This runs on ExcelJS's parsed model right before that reconcile. It only
// re-points a link to the key ExcelJS itself stored for the same part, or drops
// a link (or a drawing) that would dereference nothing; nothing is added. The
// preview shows none of these parts. Lookups are own-property only, so a Target
// or rId such as `__proto__` cannot resolve through Object.prototype.
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
const REL_COMMENTS = `${REL_TYPE}comments`;
const REL_VML_DRAWING = `${REL_TYPE}vmlDrawing`;
const REL_TABLE = `${REL_TYPE}table`;
const EXCELJS_DRAWING_TARGET = /\/drawings\/([a-zA-Z0-9]+)[.][a-zA-Z]{3,4}$/;
function ownValue(map, key) {
return map && typeof key === 'string' && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
}
// The key ExcelJS gives a part it loaded from `xl/<path>` is `../<path>`
// (relative to xl/worksheets/). A Target that names the same part another way
// (`/xl/tables/table1.xml`) is rewritten to that key.
function worksheetRelativeTarget(target) {
const path = excelJsEntryName(target[0] === '/' ? target : `xl/worksheets/${target}`);
return path.startsWith('xl/') ? `../${path.slice(3)}` : null;
}
function resolveTarget(rel, map, usable) {
if (typeof rel.Target !== 'string') return false;
if (usable(ownValue(map, rel.Target))) return true;
const key = worksheetRelativeTarget(rel.Target);
if (key && usable(ownValue(map, key))) {
rel.Target = key;
return true;
}
return false;
}
function repairExcelJsModel(model) {
if (!model || typeof model !== 'object') return model;
const isObject = (value) => Boolean(value) && typeof value === 'object';
const drawings = isObject(model.drawings) ? model.drawings : {};
// ExcelJS reconciles every drawing that has rels and reads `.anchors` off it.
for (const name of Object.keys(drawings)) {
if (!isObject(drawings[name]) || !Array.isArray(drawings[name].anchors)) {
delete drawings[name];
if (isObject(model.drawingRels)) delete model.drawingRels[name];
}
}
// ExcelJS also reconciles every table it parsed, so one it parsed to nothing goes.
if (isObject(model.tables)) {
for (const key of Object.keys(model.tables)) if (!isObject(model.tables[key])) delete model.tables[key];
}
const worksheetRels = isObject(model.worksheetRels) ? model.worksheetRels : {};
// Worksheets can share a sheetNo (ExcelJS's worksheet pattern is unanchored),
// and then they share one rels list: filter and index each list ONCE, or a
// long list costs once per worksheet.
const relsBySheetNo = new Map();
for (const worksheet of Array.isArray(model.worksheets) ? model.worksheets : []) {
if (!isObject(worksheet)) continue;
const sheetNo = String(worksheet.sheetNo);
let byId = relsBySheetNo.get(sheetNo);
if (!byId) {
const list = ownValue(worksheetRels, sheetNo);
const rels = Array.isArray(list) ? list : [];
const kept = rels.filter((rel) => {
if (!isObject(rel)) return false;
if (rel.Type === REL_COMMENTS) return resolveTarget(rel, model.comments, isObject);
if (rel.Type === REL_VML_DRAWING) {
return resolveTarget(rel, model.vmlDrawings, (vml) => isObject(vml) && Array.isArray(vml.comments));
}
if (rel.Type === REL_TABLE) return resolveTarget(rel, model.tables, isObject);
return true;
});
if (Array.isArray(list)) worksheetRels[sheetNo] = kept;
byId = new Map();
for (const rel of kept) byId.set(rel.Id, rel);
relsBySheetNo.set(sheetNo, byId);
}
// `<tablePart r:id>`, `<drawing r:id>` and `<hyperlink r:id>` are followed
// through the rels unchecked, so each must name a rel that is still there.
if (Array.isArray(worksheet.tables)) {
worksheet.tables = worksheet.tables.filter((part) => {
const rel = isObject(part) ? byId.get(part.rId) : undefined;
return Boolean(rel) && rel.Type === REL_TABLE;
});
}
if (isObject(worksheet.drawing)) {
const rel = byId.get(worksheet.drawing.rId);
const match = rel && typeof rel.Target === 'string' ? rel.Target.match(EXCELJS_DRAWING_TARGET) : null;
if (!rel || typeof rel.Target !== 'string' || (match && !ownValue(drawings, match[1]))) {
worksheet.drawing = undefined;
}
}
if (Array.isArray(worksheet.hyperlinks)) {
worksheet.hyperlinks = worksheet.hyperlinks.filter(
(link) => isObject(link) && (!link.rId || byId.has(link.rId))
);
}
}
return model;
}
function parseCellRef(ref) {
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
if (!match) return null;
@@ -983,6 +1092,7 @@
createXmlCounter,
admitXlsx,
buildAdmittedArchive,
repairExcelJsModel,
excelJsEntryName,
parseCellRef,
parseRange,
+298 -1
View File
@@ -716,6 +716,19 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
text-align: left;
}
/* A tab just arrived inside this collapsed group: a short highlight on its header. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
color: var(--text);
background: color-mix(in srgb, var(--accent) 22%, transparent);
transition: background-color 0.6s ease-out, color 0.6s ease-out;
}
@media (prefers-reduced-motion: reduce) {
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
transition: none;
}
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer;
user-select: none;
@@ -815,6 +828,141 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layo
user-select: none;
}
/* --- Focus section (pinned shortcuts, app.js _renderTabFocus) ---------- */
/* Its own element above #sessionTabs in the rail, so it shows above the flat
rail and the grouped one alike and never takes part in the list's order,
tree or Alt+N numbering. Capped so a long Focus never starves the list. */
.tab-focus {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus:not([hidden]) {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
max-height: 40%;
min-height: 0;
padding: 0.35rem 0.35rem 0.3rem calc(0.35rem + 24px);
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 4px 6px 2px;
border: 0;
border-radius: 4px;
color: var(--text);
background: transparent;
font: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: left;
cursor: pointer;
user-select: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle:hover {
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail :is(.tab-focus-toggle, .tab-focus-item):focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-focus-title {
flex: 1 1 auto;
min-width: 0;
}
.tab-focus-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.tab-focus--collapsed .tab-layout-group-chevron::before {
content: '\25B8';
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list[hidden] {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item {
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 3px 8px;
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
border-radius: 4px;
color: var(--text-dim, var(--text-muted));
background: transparent;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item:hover {
color: var(--text);
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item[aria-current='true'] {
color: var(--text);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.tab-focus-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The tab's own status dot and alert colour, so a shortcut says what its tab
says (the row keeps its full alert ring; a shortcut only recolours the dot). */
.tab-focus-item .tab-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--green);
}
.tab-focus-item .tab-status.error { background: var(--red); }
.tab-focus-item .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
.tab-focus-item.tab-alert-action .tab-status { background: var(--red); box-shadow: 0 0 6px rgba(239, 68, 68, 0.7); }
.tab-focus-item.tab-alert-idle .tab-status { background: var(--yellow); box-shadow: 0 0 6px rgba(234, 179, 8, 0.6); }
.tab-focus-item .tab-web-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--text-dim, var(--text-muted));
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
cursor: grabbing;
}
@@ -2927,6 +3075,14 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions
color: var(--red);
}
/* A child session's placement note: information, not an action (no focus, no hover). */
.tab-rail-action-menu .tab-rail-action-menu-note {
padding: 0.45rem 0.6rem;
color: var(--text-muted);
font-size: 0.85em;
cursor: default;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
display: grid;
grid-template-columns: minmax(112px, 1fr) 44px;
@@ -2990,6 +3146,14 @@ body.solo-mode .btn-lifecycle-log {
overflow: hidden;
text-overflow: ellipsis;
}
/* Detach Tiles: the title is a handle, dragged onto a Codeman window's tiles
to dock the session there (tile-grid.js _installSoloTileHandle). */
.solo-session-title.solo-session-title--handle {
cursor: grab;
}
.solo-session-title.solo-session-title--handle:active {
cursor: grabbing;
}
/* "Session unavailable" overlay for a solo window whose session has ended. */
.solo-gone-overlay {
position: fixed;
@@ -5996,6 +6160,12 @@ html[data-skin="og"] .welcome-primary:hover {
display: none;
}
/* Mobile Attach button (wrapper holds the button and its file input) - hidden on
desktop; the phone block in mobile.css shows it */
.btn-attach-mobile-wrap {
display: none;
}
/* Mobile case settings gear - hidden on desktop */
.btn-case-settings-mobile {
display: none;
@@ -11375,6 +11545,132 @@ kbd {
display: flex;
}
/* Folder grid (panels-ui.js openFolderGrid): one step BELOW the file preview, so a
tile opens its preview on top and closing that preview lands back on the grid. */
.folder-grid-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop);
z-index: 5090;
display: none;
align-items: center;
justify-content: center;
}
.folder-grid-overlay.visible {
display: flex;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.folder-grid-window {
width: 92vw;
max-width: 1400px;
height: 88vh;
background: var(--floating-bg);
border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: var(--elevated-shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.folder-grid-folders {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--border);
max-height: 6.5rem;
overflow-y: auto;
flex-shrink: 0;
}
.folder-grid-folders:empty {
display: none;
}
.folder-grid-chip {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--control-border);
background: var(--bg-input);
color: var(--text);
cursor: pointer;
}
.folder-grid-chip:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
.folder-grid-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
background: var(--bg-dark);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-auto-rows: min-content;
gap: 0.6rem;
}
.folder-grid-tile {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.3rem;
border: 1px solid transparent;
border-radius: 8px;
background: none;
color: var(--text);
cursor: pointer;
text-align: left;
min-width: 0;
}
.folder-grid-tile:hover,
.folder-grid-tile.current {
border-color: var(--accent, #4ea1ff);
}
.folder-grid-thumb {
aspect-ratio: 4 / 3;
border-radius: 6px;
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted, #888);
}
.folder-grid-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.folder-grid-name {
font-family: var(--font-mono);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-grid-empty {
grid-column: 1 / -1;
padding: 2rem;
text-align: center;
color: var(--text-muted, #888);
}
.file-preview-window {
width: 80vw;
max-width: 900px;
@@ -19386,7 +19682,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
/* No gutter of their own. */
.modal,
.file-preview-overlay {
.file-preview-overlay,
.folder-grid-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
+4
View File
@@ -101,6 +101,10 @@ self.addEventListener('fetch', (event) => {
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
if (request.url.includes('/api/')) return;
// A rendered HTML page is served `no-store` behind a capability that logout
// revokes; caching a popped-out page here would let the offline fallback
// serve it after the capability is gone.
if (request.url.includes('/html-view/')) return;
event.respondWith(
fetch(request)
+196 -10
View File
@@ -13,7 +13,7 @@
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
* row) applied optimistically and saved through ONE serialized
* row, pin a row to Focus) applied optimistically and saved through ONE serialized
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
* version conflict.
*
@@ -32,17 +32,26 @@
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
// The Focus section's collapse, per device. Its own key, not an entry in the
// group list above: that list is garbage-collected against the layout's group
// ids, and Focus is not a group.
const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
// `placement: 'manual'` must survive the round trip: the browser writes whole
// layouts back, and dropping it would re-attach a hand-placed child session to
// its parent's subtree on the next save.
const copyRef = (r) =>
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
// `placement: 'manual'` and `focus: true` must survive the round trip: the
// browser writes whole layouts back, so a field dropped here is erased on the
// next save, by whoever saves. Losing placement re-attaches a hand-placed child
// session to its parent's subtree; losing focus unpins every Focus shortcut.
const copyRef = (r) => {
const copy = { kind: r.kind, id: r.id };
if (r.placement === 'manual') copy.placement = 'manual';
if (r.focus === true) copy.focus = true;
return copy;
};
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
@@ -108,6 +117,52 @@
}
}
/**
* The Focus section's per-device collapse. `ok: false` means the store threw;
* any value other than 'true' reads as expanded.
*/
function loadFocusCollapsed(storage) {
try {
return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true };
} catch (_error) {
return { collapsed: false, ok: false };
}
}
function saveFocusCollapsed(storage, collapsed) {
try {
storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false');
return { collapsed: collapsed === true, ok: true };
} catch (_error) {
return { collapsed: false, ok: false };
}
}
/**
* The refs pinned to the Focus section that this browser can draw: focused,
* a live session or an open web tab, each once, in stored order (groups in
* order, then Ungrouped). Works with or without named groups, so Focus also
* shows above the flat rail. A ref whose session closed or whose web tab is
* not open here simply drops out; the server prunes deleted refs on its own.
*/
function focusRefs(layoutInput, options = {}) {
if (!layoutInput) return [];
const layout = normalizeLayout(layoutInput);
const live = new Set(asIds(options.liveSessionIds));
const open = new Set(asIds(options.openWebviewIds));
const seen = new Set();
const result = [];
for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) {
if (ref.focus !== true) continue;
if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue;
const key = refKey(ref);
if (seen.has(key)) continue;
seen.add(key);
result.push({ kind: ref.kind, id: ref.id });
}
return result;
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
@@ -131,11 +186,19 @@
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
* `options.parents` (child session id -> parent session id) places a live
* session the layout has not stored yet right after its parent's rows when
* the parent IS stored: that is where the server puts a child that follows its
* parent, so a spawned session appears in its parent's group at once instead
* of jumping there from Ungrouped once the layout re-read lands.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
* `focusRefs` is focusRefs() for the same options: shortcuts, never rows,
* so they are in none of the other fields.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
@@ -152,6 +215,7 @@
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const withFollowers = followingChildren(layout, liveSessionIds, options.parents);
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
@@ -182,14 +246,14 @@
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
@@ -200,7 +264,7 @@
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
}
const ALERT_RANK = { action: 2, idle: 1 };
@@ -227,6 +291,47 @@
return result;
}
/**
* For project(): returns `expand(refs)`, which inserts each live, not-yet-stored
* child session right after its parent's rows in the parent's container. A
* child of such a child follows it the same way. Without `parents` it is the
* identity, so callers that pass no lineage see no change.
*/
function followingChildren(layout, liveSessionIds, parents) {
if (!parents || typeof parents !== 'object') return (refs) => refs;
const stored = new Set(refLocations(layout).map((item) => refKey(item.ref)));
const accepted = new Set();
const pending = new Map();
// Accept parents before their children (bounded: one pass per accepted child).
for (let changed = true; changed; ) {
changed = false;
for (const id of liveSessionIds) {
const key = `session:${id}`;
const parentId = parents[id];
if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue;
const parentKey = `session:${parentId}`;
if (!stored.has(parentKey) && !accepted.has(parentKey)) continue;
accepted.add(key);
if (!pending.has(parentKey)) pending.set(parentKey, []);
pending.get(parentKey).push({ kind: 'session', id });
changed = true;
}
}
if (!pending.size) return (refs) => refs;
return (refs) => {
const out = [...refs];
for (const [parentKey, children] of pending) {
const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents);
let at = -1;
out.forEach((ref, index) => {
if (block.has(refKey(ref))) at = index;
});
if (at >= 0) out.splice(at + 1, 0, ...children);
}
return out;
};
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
@@ -418,6 +523,35 @@
return keys;
}
/**
* How a child session is placed, for the row menu. null for a session with no
* parent (or not stored). Otherwise `{ state, parentId, canFollow }`:
* - inherited: it follows its parent (moves with it).
* - manual: placed by hand; "Follow parent again" puts it back.
* - dangling: its parent is not in this layout (closed, or another owner's),
* so it stands on its own; nothing re-adopts it automatically.
* - cycle: the parent chain loops back to it; it stands on its own.
*/
function placementState(layoutInput, ref, parents) {
const layout = normalizeLayout(layoutInput);
if (!validRef(ref) || ref.kind !== 'session') return null;
const parentId = parents?.[ref.id];
if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null;
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const own = stored.get(refKey(ref));
if (!own) return null;
if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false };
// Walk up the stored ancestors; meeting this session again is a loop.
const seen = new Set([ref.id]);
for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) {
if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false };
seen.add(id);
}
return own.placement === 'manual'
? { state: 'manual', parentId, canFollow: true }
: { state: 'inherited', parentId, canFollow: false };
}
/**
* Where a moved row lands, as the server's `index` (counted AFTER the moved
* block is taken out): before or after `anchor` in that container, or at its
@@ -526,6 +660,17 @@
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout;
}
case 'setFocus': {
// Pin or unpin one row. It never moves: Focus draws a shortcut, and the
// row stays in its group. Idempotent, so a replay after a 409 is safe.
if (!validRef(op.ref)) editError('invalid row');
if (typeof op.focused !== 'boolean') editError('invalid Focus state');
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref));
if (!location) editError('unknown row');
if (op.focused) location.ref.focus = true;
else delete location.ref.focus;
return layout;
}
case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref);
@@ -546,6 +691,43 @@
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout;
}
case 'followParent': {
// Clear a hand placement: the child (with the sessions that follow it)
// goes back after its parent's rows. The server re-derives the exact
// order on save; this only has to put the row in the right group now.
const state = placementState(layout, op.ref, op.parents);
if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow');
if (state.state !== 'manual') return layout;
const targetKey = refKey(op.ref);
// Drop the placement only: a Focus pin stays (the whole layout is PUT,
// so a field left off here would be erased on save).
const clear = (ref) => {
if (refKey(ref) !== targetKey) return ref;
const copy = copyRef(ref);
delete copy.placement;
return copy;
};
for (const group of layout.groups) group.refs = group.refs.map(clear);
layout.ungrouped = layout.ungrouped.map(clear);
const childKeys = new Set();
for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key);
const block = refLocations(layout)
.filter((item) => childKeys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref)));
const parentRef = { kind: 'session', id: state.parentId };
const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef));
const container = containerRefs(layout, home.groupId);
const parentBlock = lineageBlock(layout, parentRef, op.parents);
let at = -1;
container.forEach((ref, index) => {
if (parentBlock.has(refKey(ref))) at = index;
});
container.splice(at + 1, 0, ...block);
return layout;
}
default:
return editError(`unknown operation ${op.type}`);
}
@@ -739,12 +921,16 @@
applyOperation,
moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
placementState,
dropOperation,
contentKey,
createEditCoordinator,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
focusRefs,
loadFocusCollapsed,
saveFocusCollapsed,
MAX_GROUPS,
};
})(typeof window !== 'undefined' ? window : globalThis);
+22 -5
View File
@@ -327,15 +327,32 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// Group placement (vertical rail with a tab layout only; [] elsewhere).
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
// A tile (Detach Tiles on) opens its own size, over where it is: the
// keyboard's way to what dragging its header out of the window does.
...(this._tileGrid?.has?.(sessionId) && this._tileDetachAllowed?.()
? [{ label: 'Open in a new window', run: () => this.detachTile(sessionId) }]
: (this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
// A note (a child session's placement) is plain text, not a menu item: it
// takes no focus and no arrow stop, and describes the menu instead.
if (action.note) {
const note = document.createElement('div');
note.className = 'tab-rail-action-menu-note';
note.setAttribute('role', 'none');
note.id = 'tabRailActionMenuNote';
note.textContent = action.label;
menu.setAttribute('aria-describedby', note.id);
menu.appendChild(note);
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
+44 -24
View File
@@ -25,13 +25,15 @@
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
* so the wheel pages the CLI's own transcript with PageUp/PageDown
* (_maybePageCliTranscript) through the primary pane's gates, plus an
* overflow-row discount for this pane's capture-before-resize load
* (_localRows), and only while the viewport is on the live screen (a
* wheel-down from those overflow rows is xterm's, and brings it home).
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* on the alternate screen, Claude's repaint mode) leaves the xterm no
* scrollback, so the wheel pages the CLI's own transcript with
* PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
* and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
* an overflow-row discount for this pane's capture-before-resize load
* (_localRows), only while the viewport is on the live screen (a wheel-down
* from those overflow rows is xterm's, and brings it home), and never while
* THIS pane's session has a dialog open.
* - The desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener), sent
@@ -48,8 +50,8 @@
* Android tablet, or a large foldable unfolded in landscape, reaches.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -204,9 +206,10 @@
// flag, app._linkHovered, belongs to its terminal alone).
this._linkHovered = false;
this._onFocusIn = null;
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
// (travel short of a whole page, its direction and last event time), the
// state CodemanTerminalInput.pageKeysForGesture hands back.
this._pageKeyGesture = null;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
@@ -243,6 +246,9 @@
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
// OSC 52 clipboard writes from this pane's PTY, gated on THIS pane's
// buffer load so a replay never re-copies (terminal-ui.js).
global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
@@ -770,8 +776,15 @@
try {
if (this._destroyed) return;
const shell = this.sessionMode === 'shell';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
// constants.js), never the raw byte recording; a grid tile bounds it
// by its own scrollback instead.
let query = shell ? SHELL_LOAD_QUERY : 'full=1';
if (this.boundedLoad) {
query = shell
? `${SHELL_LOAD_BASE_QUERY}${this._historyLinesQuery()}`
: `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
}
this._loadAbort = controller;
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
let payload;
@@ -1147,17 +1160,20 @@
// Hollow-buffer paging, the twin of the primary pane's
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
// the primary pane's own, asked for THIS pane (its terminal, its session,
// never the active one), so the CLI rules stay in terminal-ui.js and this
// file names no CLI. Returns true when the wheel was consumed here.
// draws in place (on the alternate screen, or Claude's repaint mode) leaves
// this xterm no scrollback, so a wheel scrolled nothing; instead the travel
// pages the CLI's own transcript with PageUp/PageDown. Every gate is the
// primary pane's own, asked for THIS pane (its terminal, its session, never
// the active one), so the CLI rules stay in terminal-ui.js and this file
// names no CLI. The gesture rules (first-event page, notch accumulation,
// pinch and sideways-swipe consume) are the shared pure
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
// Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
if (!app || !input?.pageKeysForGesture || !input.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it (a shell running htop), as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
@@ -1181,8 +1197,12 @@
if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return false;
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
this._pageKeyPending = step.pending;
// An open dialog (a pending permission prompt or question) in THIS tile's
// session, which is usually not the active one: a page key would move its
// selector. Consumed, so xterm does not scroll the rows under it either.
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
return true;
}
@@ -1536,7 +1556,7 @@
clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
this._scrollBytes = '';
this._pageKeyPending = 0;
this._pageKeyGesture = null;
this._detachSocket();
if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
+298 -55
View File
@@ -5,7 +5,7 @@
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions)
* @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants)
* @dependency constants.js (DEC_SYNC_STRIP_RE, OSC52_STRIP_RE, OSC52_DEDUPE_MS, TIMING constants)
* @dependency mobile-handlers.js (MobileDetection)
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
* @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay)
@@ -79,7 +79,7 @@
const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
// gesture for a CLI whose local buffer holds no scrollback
// (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~';
@@ -92,6 +92,20 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
// cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3;
// A page-key scroll event after this much quiet, or in the opposite direction,
// starts a NEW gesture, and the first event of a gesture pages at once rather
// than waiting for half a screen of travel (a trackpad flick never got there).
const PAGE_KEY_GESTURE_GAP_MS = 150;
// ...but not for sub-row jitter (0.1 row, ~2px)...
const PAGE_KEY_MIN_START_ROWS = 0.1;
// ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px,
// 4 rows) accumulates toward half a screen as before; paging at once on it made
// slow notches, each its own gesture past the gap, page a full screen apiece.
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
// A pre-paid page's debt outlives the gesture gap for this long when the next
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
const PAGE_KEY_DEBT_MS = 1000;
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
@@ -133,13 +147,17 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// Rows of travel per page key for a terminal `rows` tall (half a screen).
function pageKeyRows(rows) {
return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
}
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel
// left over plus the keys to send ('' below one page). The arithmetic of the
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps
// its own pending travel) pages identically.
// left over plus the keys to send ('' below one page). The plain accumulation
// step of pageKeysForGesture below.
function pageKeysForTravel(pending, lines, rows) {
const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
const perPage = pageKeyRows(rows);
const total = (pending || 0) + lines;
const pages = Math.trunc(total / perPage);
const keys = pages
@@ -148,6 +166,77 @@
return { pending: total - pages * perPage, keys };
}
// One wheel or touch event of a hollow-pane gesture → the PageUp/PageDown keys
// to send, for a terminal `rows` tall, at time `now` (ms). Pure: `state` is
// the previous call's returned state (null or {} for a fresh pane), and the
// caller keeps it, so the primary pane and every TerminalTile page with the
// same gesture rules through their own state:
// - a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel) and a mostly
// horizontal swipe (both deltas present, |deltaX| > |deltaY|) send nothing
// and leave the state alone (the caller still consumes them);
// - a NEW gesture starts on the first event, on a different touch contact
// (`ev.touchId`, set by the touch paths per touchstart and carried by that
// contact's momentum; a wheel event has none, so wheel and touch never
// share a gesture), or, for events outside any contact, after quiet:
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid
// page is still owed in the same direction. One contact is never split by
// time, so a slow drag (one line per 170 ms) is one gesture;
// - a new gesture's trackpad-sized opening event (at least
// PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE,
// pre-paid: the half screen it skipped is owed back by the rest of the
// gesture, so the rate stays one page per half screen. A debt left by a
// pre-paid page dies with its gesture;
// - a direction change inside a gesture (a reversal within the gap, or
// within the same contact) drops a pre-paid debt and accumulates, never
// pre-paying, so a +/-3 px trackpad wobble cannot page back and forth;
// - everything else, a wheel notch included, accumulates through
// pageKeysForTravel, carried across notches.
// Returns { state, keys } with keys '' when nothing is due.
function pageKeysForGesture(state, ev, lines, rows, now) {
const prev = state || {};
if (ev?.ctrlKey) return { state: prev, keys: '' };
const dx = Math.abs(ev?.deltaX || 0);
const dy = Math.abs(ev?.deltaY || 0);
if (dx && dy && dx > dy) return { state: prev, keys: '' };
const perPage = pageKeyRows(rows);
const dir = lines < 0 ? -1 : 1;
const touchId = typeof ev?.touchId === 'number' ? ev.touchId : null;
const prevTouchId = typeof prev.touchId === 'number' ? prev.touchId : null;
let pending = prev.pending || 0;
let prepaid = !!prev.prepaid;
const reversed = dir !== prev.dir;
let gestureStart;
if (typeof prev.lastAt !== 'number' || touchId !== prevTouchId) {
gestureStart = true;
} else if (touchId !== null) {
gestureStart = false; // the same contact, however long it has been
} else {
const quiet = now - prev.lastAt;
gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS);
}
if (gestureStart || reversed) {
// A pre-paid debt belongs to its gesture and its direction.
if (prepaid) pending = 0;
prepaid = false;
}
if (gestureStart) {
const size = Math.abs(lines);
if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
return {
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId },
keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN,
};
}
}
const step = pageKeysForTravel(pending, lines, rows);
// A debt carries the sign opposite to its direction; once the travel reaches
// or crosses zero it is repaid, and what is left is plain travel (so the
// one-second debt lifetime no longer applies).
if (prepaid && step.pending * dir >= 0) prepaid = false;
return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys };
}
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
@@ -303,6 +392,11 @@
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
pageKeysForTravel,
pageKeysForGesture,
PAGE_KEY_GESTURE_GAP_MS,
PAGE_KEY_MIN_START_ROWS,
PAGE_KEY_IMMEDIATE_MAX_ROWS,
PAGE_KEY_DEBT_MS,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -612,6 +706,8 @@ Object.assign(CodemanApp.prototype, {
}
}
this._installOsc52Clipboard(this.terminal);
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._initMobileImePreview();
@@ -1056,6 +1152,10 @@ Object.assign(CodemanApp.prototype, {
let lastTime = 0;
let scrollFrame = null;
let isTouching = false;
// One id per touch contact (touchstart), carried by its touchmoves AND its
// momentum frames, so the page-key fallback treats a whole contact as one
// gesture however slowly the finger moves (pageKeysForGesture).
let touchContactId = 0;
const scrollLoop = (timestamp) => {
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
@@ -1069,7 +1169,7 @@ Object.assign(CodemanApp.prototype, {
// Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
this.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines);
}
@@ -1142,6 +1242,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0;
pixelAccum = 0;
isTouching = true;
touchContactId++;
didScroll = false;
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
// Classifying scans the whole viewport with translateToString, and
@@ -1246,7 +1347,7 @@ Object.assign(CodemanApp.prototype, {
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines);
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
this._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
@@ -1918,6 +2019,17 @@ Object.assign(CodemanApp.prototype, {
});
},
/**
* A clicked path as the preview needs it: relative paths resolved against the
* session's working directory, absolute and `~/` paths unchanged
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
* provider and the response viewer, so one path opens the same file in both.
*/
_resolveLinkedFilePath(filePath, sessionId) {
if (typeof resolveLinkedFilePath !== 'function') return filePath;
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
},
/**
* Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable.
@@ -1970,10 +2082,14 @@ Object.assign(CodemanApp.prototype, {
// both continuation kinds, the indent a hard wrap leaves on its
// continuation, and the offset↔cell mapping — because touch selection
// measures the SAME lines and the two must not disagree.
// `terminalPathLine` adds one thing on top, for links only: a path the
// program broke over lines itself (Claude Code wraps tool output short of
// the edge) is glued back together, so `…/captures/2` + `026-10-10/x.png`
// links as the one file it is.
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
const logical = window.CodemanTerminalLines?.terminalPathLine(
buffer,
bufferLineNumber - 1,
terminal.cols,
@@ -1991,12 +2107,18 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 };
};
if (!lineText || !lineText.includes('/')) {
// A path needs a `/` or an extension's `.`; a line with neither has no link.
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
callback(undefined);
return;
}
const links = [];
// Text offsets of every link so far: a later pattern must not cut a
// second, overlapping link out of one already found (a relative `b.png`
// inside a URL's `?img=b.png`).
const taken = [];
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority
//
@@ -2016,6 +2138,7 @@ Object.assign(CodemanApp.prototype, {
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
taken.push({ start: startCol, end: startCol + cleaned.length });
links.push({
text: cleaned,
@@ -2045,27 +2168,28 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
// Pattern 2: Paths with common extensions — absolute, `~/` and relative
// (`findFilePathLinks`, constants.js). Image/PDF/media extensions are
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink).
//
// The literal lives in constants.js because the response viewer linkifies
// the SAME paths out of markdown — one definition, two consumers. A fresh
// instance per call: `lastIndex` is per-object state.
const extPattern = absoluteFilePathPattern();
// The patterns live in constants.js because the response viewer linkifies
// the SAME paths out of markdown — one definition, two consumers.
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const addLink = (filePath, matchIndex) => {
const addLink = (filePath, matchIndex, folder = false) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
const start = coordAt(startCol);
const end = coordAt(startCol + filePath.length);
// Skip if already have link at this position
// Skip if already have link at this position, or one overlapping it
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
if (isTaken(startCol, filePath.length)) return;
taken.push({ start: startCol, end: startCol + filePath.length });
links.push({
text: filePath,
@@ -2088,12 +2212,29 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
self.openFilePreview(text, sessionId);
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
self.openLogViewerWindow(text, sessionId);
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
},
hover() {
setHovered(true);
@@ -2117,9 +2258,8 @@ Object.assign(CodemanApp.prototype, {
addLink(match[2], match.index);
}
extPattern.lastIndex = 0;
while ((match = extPattern.exec(lineText)) !== null) {
addLink(match[1], match.index);
for (const found of findFilePathLinks(lineText)) {
addLink(found.path, found.index, !!found.folder);
}
bashPattern.lastIndex = 0;
@@ -3669,10 +3809,27 @@ Object.assign(CodemanApp.prototype, {
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
* the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason.
*
* It is also what brings up the partial-history notice, and what retires it
* once a downward scroll is back at live output (_setHistoryNoticeRevealed).
* The reveal waits for the pull this gesture started, so the notice describes
* what the pull left rather than flashing the state it is about to replace.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines >= 0) return;
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
if (lines > 0) {
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
return;
}
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
// An earlier frame of this gesture already started the pull; its own
// completion reveals the notice, so do not flash the pre-pull state now.
if (this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
const sessionId = this.activeSessionId;
Promise.resolve(this._maybeRefetchFullHistory?.())
.catch(() => {})
.then(() => {
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
});
},
/**
@@ -4456,8 +4613,9 @@ Object.assign(CodemanApp.prototype, {
}
// Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
// (from historical SSE data that was stored with markers), and every
// OSC 52 clipboard write: a replay must never copy (see OSC52_STRIP_RE).
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '').replace(OSC52_STRIP_RE, '');
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
@@ -4923,6 +5081,51 @@ Object.assign(CodemanApp.prototype, {
return Number.isInteger(columns) && columns > 0 ? columns : 0;
},
// OSC 52 (`ESC ] 52 ; <sel> ; <base64> BEL`) is how programs inside the PTY —
// Claude Code's /copy, tmux yanks, vim — set the clipboard. The server has no
// display, so this is the only route to the browser's clipboard. Writes only:
// a `?` query (clipboard read) is ignored. A replay must never copy: the main
// terminal's replays strip OSC 52 in chunkedTerminalWrite (xterm parses after
// _isLoadingBuffer is already cleared, so that flag alone cannot hold), and a
// TerminalTile passes `isReplaying`, its own load flag, which does hold there.
// Every tab watching the session gets the sequence, so only the focused one
// opens the manual-copy dialog when the browser refuses the write.
// One copy usually arrives several times: Claude Code writes OSC 52 itself and
// through `tmux load-buffer -w`, and tmux forwards it with set-clipboard on,
// so the same text lands up to three times within a few ms. Identical text
// inside OSC52_DEDUPE_MS is that one copy, not a new one.
_installOsc52Clipboard(terminal, isReplaying = () => this._isLoadingBuffer) {
terminal.parser?.registerOscHandler?.(52, (data) => {
const sep = data.indexOf(';');
const payload = sep >= 0 ? data.slice(sep + 1) : data;
if (!payload || payload === '?' || isReplaying()) return true;
let text;
try {
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
text = new TextDecoder().decode(bytes);
} catch {
return true;
}
if (!text) return true;
const now = Date.now();
if (this._lastOsc52?.text === text && now - this._lastOsc52.at < OSC52_DEDUPE_MS) return true;
this._lastOsc52 = { text, at: now };
// _copyText's execCommand fallback (plain HTTP, or a refused Clipboard API
// write) focuses a temporary textarea; hand focus back as Auto Copy does.
const focusedBefore = document.activeElement;
this._copyText(text).then((ok) => {
if (focusedBefore && focusedBefore !== document.activeElement && focusedBefore.isConnected) {
try {
focusedBefore.focus();
} catch {}
}
if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
else if (document.hasFocus?.()) this._showClipboardFallback?.(text);
});
return true;
});
},
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined.
@@ -5713,35 +5916,38 @@ Object.assign(CodemanApp.prototype, {
},
/**
* True when this session's LOCAL scrollback is structurally empty: a pane whose
* TUI repaints one full screen in place, so tmux keeps no history for it
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
* True when this session's LOCAL scrollback is structurally empty and its CLI
* can page its own transcript: xterm's normal buffer never grows past one
* screen (`baseY === 0`), so scrolling it is a no-op no matter how the gesture
* is routed (the "wheel does nothing at all" half of the #205 retest).
*
* Two shapes, measured separately:
* - `claude` in repaint mode (the original, #205 round 2), and
* Shapes measured so far:
* - `claude` in repaint mode (the original, #205 round 2): tmux reports
* `history_size≈0` and every frame overwrites the last;
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
* terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it. Without
* this the wheel was silently dead in every opencode tab.
* byte-identical — so paging is the only gesture that reaches it;
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
* wheel reports do nothing.
*
* Every other mode is deliberately absent: shell/pi own real terminal
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
* is unverified (docs/scrollback-fix-plan.md).
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
* literal here. A missing list means no mode pages.
*
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
* calls this with its own session and terminal, so the mode list above stays
* here alone. `localRows` replaces `baseY` as the history row count: a tile
* calls this with its own session and terminal, so the mode rule stays here
* alone. `localRows` replaces `baseY` as the history row count: a tile
* discounts the stale rows its own load order leaves above the screen
* (TerminalTile._localRows). Every field left out means the primary pane's.
*/
_localScrollbackIsHollow(target = {}) {
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude' && mode !== 'opencode') return false;
const pagingModes = window.__codemanTranscriptPageKeys;
if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false;
const buf = (target.terminal || this.terminal)?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
@@ -5758,34 +5964,71 @@ Object.assign(CodemanApp.prototype, {
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* opencode, which never fills the buffer and never accepts the wheel. Before
* one that never fills the buffer and never accepts the wheel (opencode, codex:
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
*
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
* session with real local scrollback is never touched. Shift is excluded on
* purpose: it is the explicit "give me local scrollback" gesture and must keep
* that meaning.
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* `baseY === 0`) plus a false forwarding gate, so a session with real local
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
* "give me local scrollback" gesture and must keep that meaning.
*
* The FIRST event of a trackpad-sized gesture (opening under
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
* travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick
* never sent a key at all. That page is pre-paid: the travel it skipped is
* owed back by the rest of the gesture, so the rate stays one page per
* `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact
* (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is
* still owed), a reversal after the gap or a tab switch starts a new gesture;
* one contact is never split by time, and a quick reversal never pre-pays. A
* larger opening event is a wheel notch and accumulates as before, so slow
* notches still page once per `perPage` rows.
*
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
* the touch path locks its own axis before it gets here), and any gesture while
* the session has an open dialog (tab alert 'action', set by a pending
* permission_prompt or elicitation_dialog), so a page key never reaches its
* selector.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
*
* The gesture rules above live in the pure CodemanTerminalInput.pageKeysForGesture
* (top of this file); this method adds the gates and keeps the state.
*
* Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
* through the same gates and the same pageKeysForTravel arithmetic; keep the
* two in step. The tile adds one gate this pane cannot need, viewport at the
* bottom: hollow here means baseY 0, so this viewport is always there, while a
* tile is hollow with its own discounted rows still above the screen.
* through the same gates and the same pageKeysForGesture, with its own state
* and its OWN session's dialog check; keep the two in step. The tile adds one
* gate this pane cannot need, viewport at the bottom: hollow here means baseY 0,
* so this viewport is always there, while a tile is hollow with its own
* discounted rows still above the screen.
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) return false;
// Leftover travel belongs to the tab it was made on.
// An open dialog in THIS pane's session (the active one): a page key would
// move its selector. Consumed, so the buffer does not scroll under it either.
if (this.tabAlerts?.get(this.activeSessionId) === 'action') {
this._logScrollRouting('page-keys-dialog-open');
return true;
}
// Leftover travel and the gesture in progress belong to the tab they were made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
this._pageKeyGesture = null;
}
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
this._pageKeyPending = step.pending;
const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyGesture,
ev,
lines,
this.terminal?.rows,
performance.now()
);
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys');
return true;
+439 -18
View File
@@ -56,6 +56,26 @@ const TILE_GRID_REFIT_MS = 150;
// the grid section's padding (styles.css .tile-grid), for the drag math.
const TILE_DIVIDER_PX = 6;
const TILE_GRID_PADDING_PX = 4;
// What a tile's header drag (and a popped-out window's title drag) carries:
// the session id under a type of its own, never text, so no text field or
// terminal takes it as typing. Another Codeman window reads it on drop.
const TILE_DRAG_TYPE = 'application/x-codeman-tile';
// Detach Tiles: a tile dropped outside every window waits this long before it
// opens as a window of its own, so another window that took it (a
// 'tile-adopted' message on the window channel) can still say so first.
const TILE_TEAR_OFF_SETTLE_MS = 120;
// How long after its drag ended a tile can still be claimed by another
// window's drop (the claim and the dragend come from two windows).
const TILE_ADOPT_WINDOW_MS = 5000;
// A session docked here from another window: its old window's 'detached'
// announcements this soon after are stale (it is closing), not a new pop-out.
const TILE_DOCK_GRACE_MS = 2000;
// The smallest window a detached tile opens as, and roughly how much browser
// chrome sits above a pop-out's content (its title and address bars), so the
// window's own header lands near where the tile was let go.
const TILE_WINDOW_MIN_W = 480;
const TILE_WINDOW_MIN_H = 320;
const TILE_WINDOW_CHROME_PX = 60;
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
function tileGridTracks(fr) {
@@ -324,12 +344,14 @@ Object.assign(CodemanApp.prototype, {
const sessionId = tileAt(e.target);
const files = Array.from(e.dataTransfer?.files || []);
if (!sessionId || files.length === 0) return;
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
if (images.length === 0) {
this.showToast?.('Only image files are supported', 'error');
// The one classifier every pick and drop filter reads (image-input.js):
// MIME first, then the name's extension, so documents and videos count too.
const media = files.filter((f) => this._promptAttachKind?.(f));
if (media.length === 0) {
this.showToast?.('Unsupported file type', 'error');
return;
}
this._uploadAndInsertImages?.(images, { sessionId });
this._uploadAndInsertImages?.(media, { sessionId });
});
},
@@ -1552,8 +1574,10 @@ Object.assign(CodemanApp.prototype, {
* `accepts(id)` is the target's own rule (a tile takes any session but its
* own; an empty cell takes any). A session it does not accept
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
* drop follows). Any other drag (a file) is left to the grid section's own
* guard (_installTileFileDrop).
* drop follows). With Detach Tiles on, a tile dragged from ANOTHER Codeman
* window (no drag of this page's own, the tile type in the drag) is taken
* too and docks here (_dockForeignTile). Any other drag (a file) is left to
* the grid section's own guard (_installTileFileDrop).
*/
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
@@ -1561,7 +1585,16 @@ Object.assign(CodemanApp.prototype, {
'dragover',
(e) => {
const id = dragged();
if (!id) return;
if (!id) {
// A tile from another Codeman window (Detach Tiles). Its id can
// only be read on drop, so it is held here whatever it is.
if (!this._isForeignTileDrag(e)) return;
e.preventDefault?.();
e.stopPropagation?.();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
el.classList.add('tile--drop-target');
return;
}
e.preventDefault?.();
e.stopPropagation?.();
const ok = accepts(id);
@@ -1578,7 +1611,13 @@ Object.assign(CodemanApp.prototype, {
(e) => {
el.classList.remove('tile--drop-target');
const id = dragged();
if (!id) return;
if (!id) {
if (!this._isForeignTileDrag(e)) return;
e.preventDefault?.();
e.stopPropagation?.();
this._dockForeignTile(e.dataTransfer?.getData?.(TILE_DRAG_TYPE), onDrop);
return;
}
e.preventDefault?.();
e.stopPropagation?.();
if (accepts(id)) onDrop(id);
@@ -2080,7 +2119,10 @@ Object.assign(CodemanApp.prototype, {
* input starts no drag. dragstart's target is the header whatever was
* pressed, so where the press landed is noted in the capture phase, ahead of
* the buttons' own stopPropagation. `draggable` is off while moving is
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress),
* unless Detach Tiles is on: then a drop outside the window pops the tile
* out, and a drop on another Codeman window's tiles moves it there (see the
* Detach Tiles section); a rename still turns it off.
*/
_installTileMoveDrag(sessionId, header, actions) {
let pressedOnControl = false;
@@ -2094,7 +2136,10 @@ Object.assign(CodemanApp.prototype, {
);
header.addEventListener('dragstart', (e) => {
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
// Detach Tiles lets a tile that cannot move (alone, or zoomed) still be
// dragged, out of the window; no drop target here takes it then.
const detachable = this._tileDetachAllowed();
if (!entry || pressedOnControl || entry.renaming || !(this._tilesMovable() || detachable)) {
e.preventDefault?.();
return;
}
@@ -2102,11 +2147,16 @@ Object.assign(CodemanApp.prototype, {
entry.el.classList.add('tile--dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Firefox starts no drag without data.
e.dataTransfer.setData('application/x-codeman-tile', sessionId);
// Firefox starts no drag without data. Another Codeman window reads
// the id from it (Detach Tiles).
e.dataTransfer.setData(TILE_DRAG_TYPE, sessionId);
}
if (detachable) this._beginTileTearOff(sessionId, entry, e);
});
header.addEventListener('dragend', (e) => {
this._settleTileTearOff(sessionId, e);
this._endTileMoveDrag();
});
header.addEventListener('dragend', () => this._endTileMoveDrag());
},
/**
@@ -2117,6 +2167,7 @@ Object.assign(CodemanApp.prototype, {
*/
_endTileMoveDrag() {
this._draggedTileId = null;
this._stopTileTearOffTracking();
const grid = this._tileGrid;
for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
@@ -2124,19 +2175,25 @@ Object.assign(CodemanApp.prototype, {
/**
* The header as a handle: `draggable` while the tile can move (not while a
* tile is zoomed, alone, or being renamed), and its tooltip, the state and
* how long ("working 3m") plus, while it can move, that it drags. Diffs on
* the last English text, never the DOM (translated in zh-CN; see
* tile is zoomed, alone, or being renamed) or, with Detach Tiles on, be
* dragged out of the window (then also alone or zoomed), and its tooltip,
* the state and how long ("working 3m") plus what a drag does. Diffs on the
* last English text, never the DOM (translated in zh-CN; see
* _renderTileOverlay).
*/
_paintTileHandle(entry) {
const movable = this._tilesMovable();
const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
const detachable = this._tileDetachAllowed();
let hint = '';
if (movable && detachable) hint = 'Drag to move the tile, or out of the window to open it on its own';
else if (movable) hint = 'Drag to move the tile';
else if (detachable) hint = 'Drag out of the window to open the tile on its own';
const title = [entry.stateLabel, hint].filter(Boolean).join('\n');
if (entry.headerLabel !== title) {
entry.headerLabel = title;
entry.header.title = title;
}
const draggable = movable && !entry.renaming ? 'true' : 'false';
const draggable = (movable || detachable) && !entry.renaming ? 'true' : 'false';
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
},
@@ -2859,6 +2916,370 @@ Object.assign(CodemanApp.prototype, {
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
return true;
},
// ── Detach Tiles (per-device `tileDetachEnabled`, default OFF) ───────────
//
// A tile leaves the grid for a window of its own, and comes back into any
// Codeman window's grid, by dragging:
//
// - its header dropped OUTSIDE every window opens it as a pop-out window
// (detachSession, the tab's own "Open in a new window"), at the drop point
// and the tile's size;
// - its header dropped on ANOTHER Codeman window's tile or empty cell moves
// it there: that window docks it and says so on the window channel
// ('tile-adopted'), and this one lets the tile go;
// - a pop-out window's title dropped on a window's tile or empty cell docks
// the session there, and the pop-out closes.
//
// Nothing new on the server: a pop-out is another client of the same session
// (see detachSession), and a tile is a TerminalTile in whichever window has it.
// A session stays in ONE place in this browser, as with every pop-out.
/** Whether Detach Tiles is on for this device. */
tileDetachEnabled() {
return this.loadAppSettingsFromStorage?.()?.tileDetachEnabled === true;
},
/** Detach Tiles applies here: on, and a dashboard (a pop-out has no grid to drag from). */
_tileDetachAllowed() {
return !this.isSoloWindow && this.tileDetachEnabled();
},
/**
* A drag over this page that is a tile from another Codeman window: none of
* this page's own drags is in flight, Detach Tiles is on and the drag
* carries the tile type (its id is only readable on drop).
*/
_isForeignTileDrag(e) {
if (this.draggedTabId || this._draggedTileId || !this._tileGrid?.open || !this._tileDetachAllowed()) return false;
const types = e?.dataTransfer?.types;
return !!types && Array.from(types).includes(TILE_DRAG_TYPE);
},
/**
* A tile from another window dropped on one of this grid's targets: it docks
* through the target's own drop (`place`: a tile it replaces or trades
* places with, an empty cell it fills), and takes focus there (a human
* selection: the user just put it there). A session that was popped out comes
* back first (its window is asked to close). Once it is a tile here, every
* other window hears 'tile-adopted', and the one it was dragged from lets
* its tile go (_onTileAdoptedElsewhere). An id this page does not know (a
* session closed meanwhile, another user's) docks nowhere.
*
* @returns {boolean} whether the session is a tile here afterwards
*/
_dockForeignTile(sessionId, place) {
const grid = this._tileGrid;
if (!grid?.open || typeof sessionId !== 'string' || !this.sessions.has(sessionId)) return false;
const poppedOut = !!this.detachedSessions?.has(sessionId);
// A pop-out window owns its session's place: take it back before mounting
// (a detached session never mounts).
if (poppedOut) this._redock?.(sessionId);
// Already a tile here too (two windows showed it): it moves to where it
// was dropped, as a drag inside this window would move it.
place(sessionId);
if (!grid.has(sessionId)) return false;
(this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
// Asked unconditionally: a pop-out this window never heard of (opened from
// another dashboard) closes too. Dashboards ignore it; only that id's
// pop-out reacts.
this._postWindowMessage?.({ type: 'close-request', id: sessionId });
this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
return true;
},
/**
* True for a session this window docked from another one a moment ago: a
* 'detached' announcement from its old pop-out is that window on its way
* out, never a new pop-out (it would take the tile straight back off).
*/
_tileDockedRecently(sessionId) {
const at = this._tileDockedAt?.get(sessionId);
return typeof at === 'number' && Date.now() - at < TILE_DOCK_GRACE_MS;
},
/**
* Another window docked `sessionId` ('tile-adopted'). Only the window it
* was dragged FROM acts: its drag is still settling or ended a moment ago.
* The tile goes without taking the keyboard anywhere (the user is in the
* other window now). The last tile leaving closes the grid onto the welcome
* screen, never onto that session: two windows sizing one PTY garble it.
*/
_onTileAdoptedElsewhere(sessionId) {
const recent = this._lastTileDrag;
// A header drag counts even without a tear-off (Detach Tiles off here): the
// other window can take the tile before this drag ends.
const dragging = this._tileTearOff?.id === sessionId || this._draggedTileId === sessionId;
const pending = this._tileTearOffPending?.id === sessionId;
const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
if (!dragging && !pending && !justEnded) return false;
this._cancelTileTearOff();
this._lastTileDrag = null;
const grid = this._tileGrid;
if (!grid?.has(sessionId)) return false;
// One stored grid per browser, and the window that docked the tile has
// just written it: this window losing the tile is no change of the
// user's to remember over that one (nor to forget it).
this._tilePersistHold = true;
try {
if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false });
this.closeTileGrid({ keepStored: true, reselect: false });
} finally {
this._tilePersistHold = false;
}
this.activeSessionId = null;
try {
localStorage.removeItem('codeman-active-session');
} catch {
/* Nothing stored. */
}
this.terminal?.clear();
this.showWelcome?.();
return true;
},
/**
* A header drag starts with Detach Tiles on: note the tile's size and where
* in it the press landed (the pop-out opens that size, under the pointer),
* and watch whether the pointer leaves the page. Two signals, since
* browsers differ in what dragend reports for a drop outside the window:
* its coordinates, and the page's own dragleave at the viewport's edge
* with nothing entered (cleared again by any dragover inside).
*/
_beginTileTearOff(sessionId, entry, e) {
this._stopTileTearOffTracking();
const rect = entry.el.getBoundingClientRect();
const within = (v, max) => (Number.isFinite(v) ? Math.max(0, Math.min(v, max)) : 0);
const drag = {
id: sessionId,
width: rect.width,
height: rect.height,
offsetX: within(e?.clientX - rect.left, rect.width),
offsetY: within(e?.clientY - rect.top, rect.height),
outside: false,
stop: null,
};
const onOver = () => {
drag.outside = false;
};
const onLeave = (ev) => {
if (ev.relatedTarget) return;
const x = ev.clientX;
const y = ev.clientY;
if (x <= 0 || y <= 0 || x >= window.innerWidth - 1 || y >= window.innerHeight - 1) drag.outside = true;
};
document.addEventListener('dragover', onOver, true);
document.addEventListener('dragleave', onLeave, true);
drag.stop = () => {
document.removeEventListener('dragover', onOver, true);
document.removeEventListener('dragleave', onLeave, true);
};
this._tileTearOff = drag;
},
/** The header drag is over (or the grid or tile went mid-drag): stop watching the pointer. */
_stopTileTearOffTracking() {
const drag = this._tileTearOff;
if (!drag) return;
this._tileTearOff = null;
drag.stop?.();
},
/**
* dragend of a tile's header with Detach Tiles on. Taken by a drop target
* (here or in another window: `dropEffect` is not 'none'), it stays where
* that put it. Let go OUTSIDE the window (the dragend point, or the page's
* own record of the pointer leaving), it opens as a window of its own after
* TILE_TEAR_OFF_SETTLE_MS, unless another window claims it first. Let go
* inside, on nothing, nothing happens, as before.
*/
_settleTileTearOff(sessionId, e) {
const drag = this._tileTearOff?.id === sessionId ? this._tileTearOff : null;
this._lastTileDrag = { id: sessionId, at: Date.now() };
if (!drag) return false;
this._stopTileTearOffTracking();
const effect = e?.dataTransfer?.dropEffect;
if (effect && effect !== 'none') return false;
const cx = e?.clientX;
const cy = e?.clientY;
// (0, 0) is what a browser reports when it does not know where the drop was.
const known = Number.isFinite(cx) && Number.isFinite(cy) && !(cx === 0 && cy === 0);
const outside = known ? cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight : drag.outside;
if (!outside) return false;
const sx = e?.screenX;
const sy = e?.screenY;
const placed = Number.isFinite(sx) && Number.isFinite(sy) && !(sx === 0 && sy === 0);
const placement = {
width: drag.width,
height: drag.height,
...(placed ? { screenX: sx, screenY: sy, offsetX: drag.offsetX, offsetY: drag.offsetY } : {}),
};
this._cancelTileTearOff();
const pending = { id: sessionId, timer: null };
pending.timer = setTimeout(() => {
if (this._tileTearOffPending !== pending) return;
this._tileTearOffPending = null;
this.detachTile(sessionId, placement);
}, TILE_TEAR_OFF_SETTLE_MS);
this._tileTearOffPending = pending;
return true;
},
_cancelTileTearOff() {
const pending = this._tileTearOffPending;
if (!pending) return;
this._tileTearOffPending = null;
clearTimeout(pending.timer);
},
/**
* Opens a tile as a window of its own (detachSession, which takes it out of
* the grid), the tile's size (at least TILE_WINDOW_MIN_W x MIN_H). Placed
* with its header under a screen point when given one (`screenX`/`screenY`
* and where in the tile it was held, `offsetX`/`offsetY`: a drop outside the
* window, a hand let go), otherwise over the tile itself (the ⋯ menu).
*
* @returns {boolean} whether the session is popped out afterwards
*/
detachTile(sessionId, { screenX, screenY, offsetX = 0, offsetY = 0, width, height } = {}) {
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
if (!entry || !this._tileDetachAllowed()) return false;
const rect = entry.el.getBoundingClientRect();
const availW = window.screen?.availWidth || Infinity;
const availH = window.screen?.availHeight || Infinity;
const size = (v, min, max) => Math.round(Math.min(Math.max(Number.isFinite(v) ? v : min, min), max));
const placement = {
width: size(width ?? rect.width, TILE_WINDOW_MIN_W, availW),
height: size(height ?? rect.height, TILE_WINDOW_MIN_H, availH),
};
if (Number.isFinite(screenX) && Number.isFinite(screenY)) {
placement.left = Math.round(screenX - offsetX);
placement.top = Math.round(screenY - offsetY - TILE_WINDOW_CHROME_PX);
} else if (Number.isFinite(window.screenX) && Number.isFinite(window.screenY)) {
// Over the tile: the page's own chrome sits above its viewport.
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
placement.left = Math.round(window.screenX + rect.left);
placement.top = Math.round(window.screenY + chrome + rect.top - TILE_WINDOW_CHROME_PX);
}
this.detachSession?.(sessionId, placement);
return !!this.detachedSessions?.has(sessionId);
},
// ── For the gesture overlay (packages/gesture-control, src/codeman/entry.ts)
//
// A hand is no native drag: the overlay asks where it is and what is under
// it, and every move still goes through the paths a mouse drag takes
// (dropSessionOnTile / dropSessionOnSlot, and so _reorderTiles), the
// pop-out through detachTile.
/** The tiled session whose tile is at viewport point (x, y), or null. */
tileAtPoint(x, y) {
const grid = this._tileGrid;
if (!grid?.open) return null;
const hit = document.elementFromPoint?.(x, y);
if (!hit) return null;
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(hit)) return id;
return null;
},
/** Whether a hand may pick up this tile: it can move, or (Detach Tiles) leave the window. */
canGrabTile(sessionId) {
return !!this._tileGrid?.has(sessionId) && (this._tilesMovable() || this._tileDetachAllowed());
},
/**
* What a session dragged to viewport point (x, y) would drop onto, by the
* mouse drag's own rules: another tile (it takes that tile's place, or the
* two trade places), an empty cell (it moves or joins there). A tiled
* session only while tiles can move (not zoomed). Null for anything else,
* a popped-out session included.
*
* @returns {{kind: 'tile', id: string, el: HTMLElement}|{kind: 'cell', cell: number, el: HTMLElement}|null}
*/
tileDropTargetAt(x, y, draggedId) {
const grid = this._tileGrid;
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return null;
if (grid.tiles.has(draggedId) && !this._tilesMovable()) return null;
const hit = document.elementFromPoint?.(x, y);
if (!hit) return null;
for (const [id, entry] of grid.tiles) {
if (entry.el.contains?.(hit)) return id === draggedId ? null : { kind: 'tile', id, el: entry.el };
}
for (const slot of grid.slots || []) {
if (slot.contains?.(hit)) return { kind: 'cell', cell: Number(slot.dataset.cell), el: slot };
}
return null;
},
/**
* Drops a session on a target tileDropTargetAt returned (the hand let go).
*
* @returns {boolean} whether the session is a tile afterwards
*/
dropOnTileTarget(draggedId, target) {
if (!target || !this._tileGrid?.open) return false;
if (target.kind === 'tile') this.dropSessionOnTile(draggedId, target.id);
else if (target.kind === 'cell') this.dropSessionOnSlot(draggedId, target.cell);
return !!this._tileGrid?.has(draggedId);
},
/** Whether viewport point (x, y) is over the open grid (a hand let go outside it pops the tile out). */
isOverTileGrid(x, y) {
if (!this._tileGrid?.open) return false;
const rect = document.getElementById('tileGrid')?.getBoundingClientRect?.();
return !!rect && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
},
/**
* A hand let a tile go outside the grid (Detach Tiles): it opens as a window
* of its own with its header near that point. A script opening a window
* with no click behind it is what popup blockers stop; detachSession then
* says so and the tile stays.
*/
detachTileAtPoint(sessionId, x, y, { offsetX = 0, offsetY = 0 } = {}) {
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const sx = (window.screenX || 0) + x;
const sy = (window.screenY || 0) + chrome + y;
return this.detachTile(sessionId, { screenX: sx, screenY: sy, offsetX, offsetY });
},
/**
* A pop-out window's title, as a handle (Detach Tiles): dragged onto a
* Codeman window's tile or empty cell, the session docks there as a tile
* and this window closes (_dockForeignTile, which asks it to). The same
* drag type as a tile's header; on or off follows the setting, also when it
* changes in another window.
*/
_installSoloTileHandle() {
const title = document.getElementById('soloSessionTitle');
if (!this.isSoloWindow || !title || title._codemanTileHandle) return;
title._codemanTileHandle = true;
const sync = () => {
this._cachedAppSettings = null;
const on = this.tileDetachEnabled();
title.setAttribute('draggable', on ? 'true' : 'false');
title.classList.toggle('solo-session-title--handle', on);
const label = on ? "Drag onto a Codeman window's tiles to dock this session there" : '';
if (title._codemanTileHandleLabel !== label) {
title._codemanTileHandleLabel = label;
title.title = label;
}
};
sync();
window.addEventListener('storage', (e) => {
if (!e.key || e.key === this.getSettingsStorageKey?.()) sync();
});
title.addEventListener('dragstart', (e) => {
if (!this.tileDetachEnabled() || !this.soloSessionId) {
e.preventDefault?.();
return;
}
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(TILE_DRAG_TYPE, this.soloSessionId);
}
});
},
});
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
+12
View File
@@ -325,6 +325,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
@@ -347,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
return this.openWebview(id);
},
/**
* Right-click (or the menu key) on a web tab in the vertical rail opens its
* actions (Focus, group moves, settings), the flat rail included, where it is
* the only way in. Everywhere else the browser keeps its own menu.
*/
handleWebviewTabContextMenu(event, id) {
if (!this._tabLayoutEditable?.()) return false;
return this.openTabWebviewMenu?.(event, id);
},
/** Mark exactly one tab active across BOTH tab kinds. */
_updateActiveWebviewTab() {
const container = this.$('sessionTabs');
@@ -359,6 +370,7 @@ Object.assign(CodemanApp.prototype, {
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
this._renderTabFocus?.();
},
// ── Opening / closing ─────────────────────────────────────────────────────
+9
View File
@@ -17,6 +17,15 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
/**
* Longest path PARAMETER the router accepts (Fastify's default is 100). A file
* name goes up to 255 characters and arrives percent-encoded, so the folder
* grid's `…/files/:name/raw` answered 404 "Route not found" for a name the
* listing happily showed. Raw length, not decoded: 255 four-byte characters are
* 3060 characters of `%XX`.
*/
export const MAX_ROUTE_PARAM_LENGTH = 4096;
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
+4 -1
View File
@@ -36,6 +36,7 @@ import {
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
@@ -183,7 +184,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
// Web-tab proxy capabilities are a second credential the cookie purge does not
// touch; a forced logout that left them alive would not be a logout.
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews });
const revokedHtmlViews = htmlViewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews, revokedHtmlViews });
return { success: true, data: { revoked } };
});
@@ -206,6 +208,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
}
revokeUserSessions(ctx.authSessions, username);
webviewCapabilities.revokeOwner(normalizeUsername(username));
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
+6 -8
View File
@@ -102,14 +102,12 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
// a third-party TUI whose keystroke contract Codeman has not measured, the
// Claude-shaped option parser never reads options off its frames, and
// verifyStillAnswerable() can therefore never be conclusive for it — so the
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
// still raises the red alert and clears on the harness's own working/stop
// reports; answering happens in the terminal.
if (session.mode === 'deepseek') {
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
// not measured, so '1'/Esc would be a blind keystroke into a foreign
// composer. The item still raises the red alert and clears on the harness's
// own working/stop reports; answering happens in the terminal.
if (item.answerInTerminal) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
+1 -1
View File
@@ -1536,7 +1536,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
const denied = adminOnly(req, reply);
if (denied) return denied;
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body);
// Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
+252 -34
View File
@@ -23,6 +23,7 @@ import type {
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { compileFileQuery } from '../../utils/file-query.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
AUDIO_ATTACHMENT_EXTENSIONS,
@@ -30,12 +31,21 @@ import {
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension,
registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
folderAllowedForUser,
listFolder,
validFolderFileName,
} from '../../folder-listing.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
@@ -200,8 +210,8 @@ interface FileBodySource {
cleanup?: () => void;
}
/** Byte source for a LOCAL file (the historical, only path). */
function localFileSource(resolvedPath: string) {
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
export function localFileSource(resolvedPath: string) {
return (range?: { start: number; end: number }): FileBodySource => ({
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
});
@@ -221,8 +231,11 @@ function localFileSource(resolvedPath: string) {
* `open` supplies the bytes for the (optional) range, which is what lets a remote
* case reuse this instead of re-implementing the 206/416 contract: same headers,
* same status codes, whether the file sits on this host or behind an ssh pipe.
*
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
* `<video>`/`<audio>` can seek as well.
*/
function sendFileBody(
export function sendFileBody(
reply: FastifyReply,
size: number,
rangeHeader: string | string[] | undefined,
@@ -271,7 +284,9 @@ async function serveRawFile(
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
// family is servable, so widening what can be READ never widened what can RUN.
// The preview overlay reads these through `fetch()`, which ignores the
// disposition, so a clicked .html still shows its source.
// disposition, so the source view of a clicked .html still works. Rendering it
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
// route's.
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
if (download || markupOnly) {
reply.header(
@@ -426,6 +441,40 @@ async function resolveServableRemoteAttachment(
return { path: probe.realPath, probe };
}
/**
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
* the attachment raw route, shared with the folder-grid file route.
*
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
* contract, and its size comes from the guard's own re-probe — so serving a remote
* file needs no stat the local branch would not also need.
*/
async function serveServableRaw(
reply: FastifyReply,
servable: ServableAttachment,
remote: SessionRemote | undefined,
fileName: string,
extension: string,
query: { download?: string; preview?: string; range?: string }
): Promise<void> {
try {
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(extension, query, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
}
/**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers
@@ -520,8 +569,20 @@ function getKnownSessionWorkingDir(
interface SessionFileScope {
workingDir: string;
remote?: SessionRemote;
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
docker?: boolean;
}
/**
* A Docker case is a LOCAL session for the file routes (the workspace is a
* bind mount, so its paths are host paths), but `~` inside the container is the
* container's own seeded home, not this host's. Expanding `~/x.png` against the
* host's home would open a different file than the one the agent printed, so
* the form is refused instead of guessed.
*/
export const DOCKER_HOME_PATH_ERROR =
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
/**
* The file-access scope of a session, live or persisted — the `workingDir`-only
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
@@ -539,12 +600,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) {
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
}
const stored = ctx.store.getSession(sessionId);
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
return { workingDir: stored.workingDir, remote: stored.remote };
return { workingDir: stored.workingDir, remote: stored.remote, docker: !!stored.docker };
}
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
@@ -2143,6 +2204,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return;
}
if (session.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
// A remote case registers a path that lives on the REMOTE host: the guard and
@@ -2263,34 +2328,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
});
if (!servable) return;
try {
// A remote record streams over ssh exactly like file-raw, with the same
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
// serving a remote attachment needs no stat the local branch would not also need.
const remote = session.remote;
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,
size,
record.fileName,
record.extension,
download === 'true',
req.headers.range
);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
download,
preview,
range: req.headers.range,
});
});
// Serve a converted PDF preview of a registered attachment by id. Office docs
@@ -2354,6 +2396,182 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveThumbnail(reply, servable.path, record.extension);
});
// ===== Folder grid =====
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
// grid. Same guard as a single external attachment; the grid then fetches files by
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
app.post('/api/sessions/:id/folders', async (req, reply) => {
const { id } = req.params as { id: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const body = (req.body || {}) as { path?: unknown };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
return;
}
// A bare `~` expands to this host's home too, not the container's.
if (scope.docker && (body.path === '~' || body.path.startsWith('~/'))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
const listing = await listFolder(id, body.path, {
sessionWorkingDir: scope.workingDir,
remote: scope.remote,
user: getAuthUser(req),
});
return { success: true, data: listing };
} catch (err) {
if (err instanceof FolderListingError) {
const code =
err.statusCode === 404
? ApiErrorCode.NOT_FOUND
: err.statusCode >= 500
? ApiErrorCode.OPERATION_FAILED
: ApiErrorCode.INVALID_INPUT;
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
return;
}
return reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
}
});
/**
* One file of a granted folder, guard-checked like an attachment: the name is a
* bare file name of a supported type, and the joined path goes through the same
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
* re-resolution, remote re-probe) an attachment by id does. The multi-user rule
* ({@link folderAllowedForUser}) runs again on the resolved file: a symlink inside
* the granted folder must not lead into another user's space.
*/
async function resolveFolderFile(
req: FastifyRequest,
reply: FastifyReply
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return null;
const grant = folderGrants.get(id, folderId);
if (!grant) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
return null;
}
const fileName = validFolderFileName(name);
if (!fileName) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
return null;
}
const filePath = folderFilePath(grant.dir, fileName);
if (!scope.remote) {
// Bounded, like the folder itself: the grant may point at a mount that has stopped answering since.
const kind = await probePathKind(filePath, { pastCap: true });
if (kind === 'unknown') {
reply
.code(503)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('File', filePath, { pastCap: true }))
);
return null;
}
if (kind !== 'file') {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return null;
}
}
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const record: AttachmentRecord = {
attachmentId: '',
sessionId: id,
filePath,
fileName,
extension,
attachmentType: getAttachmentType(extension),
size: 0,
mtimeMs: 0,
timestamp: 0,
source: 'external',
};
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return null;
// The requested NAME only opens the door; registration judges the file a
// symlink resolves to (supported extension, regular file) and so does this.
// Without it `shot.png -> ../secret.bin` is served, and a FIFO or a device
// behind a supported name parks a worker on an open that never returns.
if (!isSupportedAttachmentExtension(extname(servable.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Unsupported attachment type'));
return null;
}
const isRegularFile = scope.remote
? servable.probe?.kind === 'file'
: await fs.stat(servable.path).then(
(stat) => stat.isFile(),
() => false
);
if (!isRegularFile) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Attachment path is not a file'));
return null;
}
if (!folderAllowedForUser(servable.path, getAuthUser(req), scope)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File is outside your space'));
return null;
}
return { scope, servable, name: fileName, extension };
}
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
const { download, preview } = req.query as { download?: string; preview?: string };
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
download,
preview,
range: req.headers.range,
});
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.extension !== 'docx' && file.extension !== 'pptx') {
const { id, folderId } = req.params as { id: string; folderId: string };
reply.redirect(
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
);
return;
}
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Office document preview is not available for files in a remote (SSH) case'
)
);
return;
}
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Thumbnails are not available for files in a remote (SSH) case'
)
);
return;
}
await serveThumbnail(reply, file.servable.path, file.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
+8 -1
View File
@@ -14,6 +14,7 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -200,6 +201,10 @@ export function registerHookEventRoutes(
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
message: typeof safeData.message === 'string' ? safeData.message : undefined,
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
// Supervised-hook CLIs (DeepSeek Harness): dialog keystrokes are unverifiable,
// so the item is an alert — answer in the terminal. Capability-gated, not by
// mode id, so a future supervised CLI inherits the same shape.
answerInTerminal: getCli(session.mode)?.capabilities.hooks === 'supervised',
// What the pane says is still running in the background. An idle prompt from a
// session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning.
@@ -244,7 +249,9 @@ export function registerHookEventRoutes(
sessionId,
sessionName,
...safeData,
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
// Supervised-hook CLIs answer in the terminal: omit approvalId so push
// cannot offer Approve/Deny that the answer route would refuse.
...(approvalId && session && getCli(session.mode)?.capabilities.hooks !== 'supervised' && { approvalId }),
});
}
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview Render a local HTML file (an agent's report, a generated page) as a
* page instead of as source text.
*
* Routes:
* - `POST /api/sessions/:id/html-view` (authenticated) — `{ path }` of an `.html` file;
* returns `{ url }`, an `/html-view/<cap>/<file>` URL for that file.
* - `GET /html-view/:cap/*` (capability) — the HTML file and its sibling assets.
*
* ⚠️ Every response carries `Content-Security-Policy: sandbox ...` WITHOUT
* `allow-same-origin`, so the page runs in an opaque origin whether it is framed or
* opened as a top-level tab: it can run its own scripts but cannot read the Codeman
* document, its cookies or its API. That is what makes rendering safe at all; see
* html-view-capabilities.ts for why the route authenticates on a capability.
* Network access is deliberately OPEN (owner decision, see {@link HTML_VIEW_CSP}).
*
* Path policy: the HTML file itself is admitted like the file preview admits a file
* (inside the session workspace, or outside it under the attachment guard), and the
* capability then serves ONLY the files in that file's own directory tree, filtered
* by the same blocklist plus an asset-extension allowlist and a no-dotfile rule, so
* a page cannot use its own directory to pull a `.env` out through the browser.
* Because that tree is served recursively, minting is refused for an HTML file in a
* hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and
* `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a
* directory above one, see {@link isBroadHtmlViewRoot}.
*/
import type { FastifyInstance } from 'fastify';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
/** Extensions that open as a rendered page. */
export const HTML_VIEW_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm']);
/** What a rendered page may load from its own directory, with the type it is served as. */
const ASSET_TYPES: Record<string, string> = {
html: 'text/html; charset=utf-8',
htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
map: 'application/json; charset=utf-8',
txt: 'text/plain; charset=utf-8',
csv: 'text/csv; charset=utf-8',
md: 'text/plain; charset=utf-8',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
ico: 'image/x-icon',
bmp: 'image/bmp',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
wasm: 'application/wasm',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
pdf: 'application/pdf',
};
/**
* The whole Content-Security-Policy every response is served under: the `sandbox`
* directive and nothing else. It REPLACES Codeman's own policy (`default-src 'self'`
* and the rest, set earlier by registerSecurityHeaders). No `allow-same-origin`:
* that one token is the difference between "a page" and "a page that can drive
* Codeman".
*
* ⚠️ Network access is OPEN, by the owner's decision (2026-10-13, #547): a rendered
* page behaves like a file opened from disk in a browser, so there is deliberately
* no fetch directive (`default-src`, `script-src`, `connect-src`, ...). It loads
* scripts, styles, fonts, images and media from anywhere (an agent's dashboard
* pulling Chart.js from a CDN) and may fetch, XHR, open WebSockets, send beacons and
* post forms anywhere. What confines it is the opaque origin (no Codeman cookies, no
* Codeman API) and the capability serving only its own folder. Do not add a fetch
* directive, or `allow-same-origin`, without the owner. test/routes/html-view-routes.test.ts
* pins the exact value.
*/
export const HTML_VIEW_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
function extensionOf(path: string): string {
return extname(path).toLowerCase().replace(/^\./, '');
}
/** realpath when the path exists, else the plain resolved path. */
function canonical(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
/**
* The shared temp dirs, named literally: `os.tmpdir()` is `$TMPDIR` whenever that
* is set, and macOS sets it in every terminal session (`/var/folders/.../T`), so
* a server started from one would otherwise never see `/tmp` as broad.
*/
const SHARED_TEMP_DIRS = ['/tmp', '/var/tmp'];
/**
* Where drives are mounted. A page at a drive's root (WSL's `/mnt/d/report.html`)
* would be handed the whole drive, so the mount parent and each mount point in it
* count as broad.
*/
const MOUNT_PARENTS = ['/mnt', '/media', '/Volumes'];
/**
* Whether a directory is too broad to hand to a page as its asset root. The
* capability serves the HTML file's directory RECURSIVELY to the page's own
* scripts, so `/tmp/report.html` would expose every other session's scratch
* output under `/tmp`, and `~/report.html` the home tree. Refuse those roots
* (and the cases / user-space roots, which hold every workspace) outright, and
* any directory ABOVE one of them (`/home`, `/Users`, macOS's `/private`), since
* serving it recursively serves the root below it too.
*/
export function isBroadHtmlViewRoot(dir: string): boolean {
const target = canonical(dir);
const userSpaces = canonical(getUserSpacesDir());
// canonical() also maps the literal temp dirs to macOS's `/private/tmp` and `/private/var/tmp`.
const broad = [sep, homedir(), tmpdir(), ...SHARED_TEMP_DIRS, CASES_DIR, userSpaces].map(canonical);
if (broad.some((root) => isUnderTree(root, target))) return true;
for (const parent of MOUNT_PARENTS.map(canonical)) {
if (target === parent || dirname(target) === parent) return true;
}
// A user's own space (`<spaces>/<user>`) and its cases dir hold every workspace of that user.
if (dirname(target) === userSpaces) return true;
return basename(target) === 'cases' && dirname(dirname(target)) === userSpaces;
}
export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.post('/api/sessions/:id/html-view', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const body = parseBody(HtmlViewOpenSchema, req.body ?? {});
if (session.remote) {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML pages in a remote (SSH) case cannot be rendered'));
return;
}
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(body.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// A `~/` link (the terminal passes a home-relative path through, since only
// the server knows the home) is this host's home, as the attachment route
// expands it. A Docker case's `~` is the container's, so it is refused there.
let requestedPath = body.path;
if (requestedPath.startsWith('~/')) {
if (session.docker) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
requestedPath = join(homedir(), requestedPath.slice(2));
}
// Inside the workspace: the same check every file route uses. Outside it: the
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
} else {
if (!isAbsolute(requestedPath)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
try {
resolvedPath = await fs.realpath(requestedPath);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return;
}
}
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile()) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
// The page URL names the RESOLVED file, so it has to be HTML too: a
// `report.html -> notes.txt` link would otherwise mint a page for a text file.
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(resolvedPath))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// The serving route never descends into a dot segment below the capability
// root; the HTML file's own path gets the same rule, or `.cache/r.html` would
// mint a capability over a hidden directory and serve its `token.json`. Inside
// the workspace the rule covers the part below the workspace root, which is
// all the page's tree can reach, so a workspace that itself lives under a
// hidden folder (`~/.local/src/app`) can still render its pages.
const checkedPath = inWorkspace ? inWorkspace.relativePath : resolvedPath;
if (checkedPath.split(sep).some((segment) => segment.startsWith('.'))) {
reply
.code(403)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML files in hidden directories cannot be rendered'));
return;
}
if (isBroadHtmlViewRoot(dirname(resolvedPath))) {
reply
.code(403)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This folder is too broad to render a page from; move the HTML file into its own folder'
)
);
return;
}
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
return {
success: true,
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
};
});
app.get(`${HTML_VIEW_PREFIX}/:cap/*`, async (req, reply) => {
const { cap, '*': rest } = req.params as { cap: string; '*': string };
const record = htmlViewCapabilities.resolve(cap);
const notFound = () => reply.code(404).header('Content-Type', 'text/plain; charset=utf-8').send('Not found');
if (!record) return notFound();
// Fastify has already percent-decoded the wildcard. Hidden files and
// directories are never served, whatever their extension. This is the cheap
// reject on the requested name; the same two rules run again on the resolved
// path below.
const segments = (rest || '').split('/').filter(Boolean);
if (segments.length === 0 || segments.some((s) => s.startsWith('.'))) return notFound();
if (!ASSET_TYPES[extensionOf(segments[segments.length - 1])]) return notFound();
let resolvedPath: string;
try {
resolvedPath = await fs.realpath(resolve(record.rootDir, segments.join(sep)));
} catch {
return notFound();
}
// Symlinks are resolved above, so this is the real containment check, and the
// dot and extension rules have to hold for where a link LANDS as well: a
// `cfg.json -> .git/config` inside the page's folder passes both on its own name.
if (!isUnderTree(resolvedPath, record.rootDir)) return notFound();
const landed = relative(record.rootDir, resolvedPath).split(sep);
if (landed.some((s) => s.startsWith('.'))) return notFound();
// Typed by what the bytes are, the resolved name.
const contentType = ASSET_TYPES[extensionOf(resolvedPath)];
if (!contentType) return notFound();
const guard = await loadAttachmentGuardConfig();
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) return notFound();
// Re-checked per request, like the attachment serve path (file-routes.ts): a
// page minted from outside the workspace before an admin switched confinement
// on stops serving once it is on, instead of living out its capability.
if (record.outsideWorkspace && guard.confineToWorkspace) return notFound();
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile() || exceedsDownloadLimit(stat.size)) return notFound();
reply.header('Content-Security-Policy', HTML_VIEW_CSP);
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
// `data.json` is CORS-checked with `Origin: null`. `*` without credentials is
// enough, and widens nothing: these bytes are already readable by anyone
// holding the capability URL.
reply.header('Access-Control-Allow-Origin', '*');
// The file routes' body sender: Content-Length, and `Range` answered with 206
// (or 416), so a page's own <video>/<audio> can seek. It hijacks the reply, and
// carries every header set above across to it.
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
return reply;
});
}
+1
View File
@@ -6,6 +6,7 @@ export { registerPushRoutes } from './push-routes.js';
export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerHtmlViewRoutes } from './html-view-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
+278 -38
View File
@@ -11,6 +11,7 @@ import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { finished } from 'node:stream/promises';
import { randomBytes, randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
@@ -41,6 +42,7 @@ import {
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
CreateSessionSchema,
SessionNameSchema,
@@ -155,7 +157,8 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayout
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkRemoteTmuxAvailable,
@@ -165,6 +168,7 @@ import {
toAttachedSessionRemote,
toSessionRemote,
} from '../../remote-hosts.js';
import { fetchRemoteScrollback } from '../../remote-pane-history.js';
import {
checkDockerAvailable,
checkDockerConfigDrift,
@@ -359,6 +363,19 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
const brand = data.subarray(8, 12).toString('ascii');
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
}
case '.mp4':
case '.m4v':
case '.mov':
// ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
// vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
// QuickTime file can open on an atom other than ftyp.
return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
case '.webm':
// EBML header (Matroska's WebM subset)
return u32be(0) === 0x1a45dfa3;
case '.ogv':
// Ogg container ("OggS")
return u32be(0) === 0x4f676753;
default:
return false;
}
@@ -376,7 +393,9 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
* touched, and a file already there is theirs and stays as it is.
*/
async function ensureUploadDir(workingDir: string): Promise<string | null> {
// workingDir is guaranteed to exist (live session).
// workingDir exists (live session) and the caller bounded-probed its mount, so
// the lstat/mkdir below normally start on a reachable path; a mount that dies
// after that probe is the same residual race POST /api/sessions accepts.
const uploadDir = join(workingDir, UPLOADS_DIR);
try {
const dirStat = await fs.lstat(uploadDir);
@@ -409,37 +428,70 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
return uploadDir;
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
/** Open a fresh, uniquely named paste file for writing. */
async function openPasteFile(imageDir: string, ext: string) {
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
const filepath = join(imageDir, filename);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
const fh = await fs.open(
filepath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
);
return { filename, filepath, fh };
}
// Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
// a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
// 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
// bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
// memory against a flood of unique IP keys.
const PASTE_RATE_TOKENS = 30;
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
const VIDEO_RATE_TOKENS = 3;
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
const PASTE_BUCKET_GC_THRESHOLD = 1000;
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
const videoRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
for (const [k, b] of pasteRateBuckets) {
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
function takeToken(
buckets: Map<string, { tokens: number; lastRefill: number }>,
key: string,
capacity: number,
now: number
): boolean {
if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
for (const [k, b] of buckets) {
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) buckets.delete(k);
}
}
let b = pasteRateBuckets.get(key);
let b = buckets.get(key);
if (!b) {
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
pasteRateBuckets.set(key, b);
b = { tokens: capacity, lastRefill: now };
buckets.set(key, b);
}
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
b.lastRefill = now;
if (b.tokens < 1) return false;
b.tokens -= 1;
return true;
}
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
}
// Videos stream to disk under a far larger per-file cap, so they draw on their own,
// much lower bucket (see the comment above the constants).
export function consumeVideoToken(key: string, now: number = Date.now()): boolean {
return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
}
// Test hook: reset between runs.
export function _resetPasteRateBuckets(): void {
pasteRateBuckets.clear();
videoRateBuckets.clear();
}
/**
@@ -945,6 +997,7 @@ export function registerSessionRoutes(
// logout has to retire them too or every dashboard URL opened during this
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
webviewCapabilities.revokeOwner(ownerFor(req));
htmlViewCapabilities.revokeOwner(ownerFor(req));
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
return {};
});
@@ -1276,7 +1329,9 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
await ctx.addSession(session);
const tabLayout = body.tabGroupId
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
: await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -1294,7 +1349,9 @@ export function registerSessionRoutes(
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
return { session: lightState };
// A grouped creation also returns the layout that placed it, so the browser
// draws the tab inside its group at once instead of after a re-read.
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
});
// ========== Rename Session ==========
@@ -3088,10 +3145,12 @@ export function registerSessionRoutes(
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
// alt=recording - With full=1: if the pane is in the alternate screen, load
// the visible frame over the byte recording instead
app.get('/api/sessions/:id/terminal', async (req, reply) => {
const routeStartedAt = performance.now();
const { id } = req.params as { id: string };
const query = req.query as { tail?: string; full?: string; lines?: string };
const query = req.query as { tail?: string; full?: string; lines?: string; alt?: string };
const session = findSessionOrFail(ctx, id, req);
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
@@ -3099,7 +3158,7 @@ export function registerSessionRoutes(
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
// no-param callers — keep the fast visible-frame capture.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = query.full === '1' || query.full === 'true';
let isFullReload = query.full === '1' || query.full === 'true';
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
// Prepend the live tmux pane buffer so tab-switch replay shows the current
@@ -3117,17 +3176,56 @@ export function registerSessionRoutes(
// for. See PaneCaptureOptions.capturedGeometry.
// `lines` bounds only the history a FULL capture reads; the visible-frame
// path reads no history and is untouched by it.
const captureOpts: PaneCaptureOptions = isFullReload
let captureOpts: PaneCaptureOptions = isFullReload
? {
fullHistory: true,
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
maxCaptureBytes: terminalBufferMaxBytes,
}
: {};
const liveMuxBuffer =
// A remote session's local pane only sees the remote tmux client's repaints,
// so its own scrollback is a fraction of the real one: read that from the
// remote tmux and splice it above the local frame. Null (host unreachable,
// no remote session, back-off) keeps the local capture as before.
if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
const remoteScrollback = await fetchRemoteScrollback(
session.remote,
session.id,
captureOpts.historyLimitLines,
terminalBufferMaxBytes
);
if (remoteScrollback !== null) {
captureOpts.scrollbackOverride = remoteScrollback.scrollback;
captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
}
}
const capture = (opts: PaneCaptureOptions): string | null =>
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
? ctx.mux.captureActivePaneBuffer(muxName, opts)
: null;
let liveMuxBuffer = capture(captureOpts);
// tmux adds no history while a pane is in the alternate screen, and a
// nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
// in a shell) holds its pane there for good, so the full capture above is
// one screen plus whatever came before. The byte recording still carries
// the scrollback, so a caller that opts in (a shell's automatic load,
// `alt=recording`) gets the old visible-frame load over it, tailed, rather
// than losing everything above the screen. `alternate_on` cannot tell a
// nested tmux from vim or less, so those take this path too while open:
// the load the shell had before tmux-rendered loads existed. Not when the
// history came from the remote tmux (`scrollbackOverride`): a remote pane is
// in the alternate screen only because it hosts that tmux's client, and
// the fallback would throw the remote scrollback away.
if (
isFullReload &&
query.alt === 'recording' &&
captureOpts.scrollbackOverride === undefined &&
captureOpts.capturedAlternateOn === true
) {
isFullReload = false;
captureOpts = {};
liveMuxBuffer = capture(captureOpts);
}
const captureFinishedAt = performance.now();
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
@@ -3168,6 +3266,31 @@ export function registerSessionRoutes(
let truncationReason: 'capped' | 'tail' | null = null;
let cleanBuffer: string;
// A `lines=` bound shallower than tmux's history left the older lines in
// tmux: an intentional partial read, so 'tail', and a later pull with a
// deeper (or no) bound recovers them. Without this a bounded window looks
// like the whole history to the client. The byte cap below still wins.
// Only a bound the CLIENT set and below the configured ceiling counts:
// without `lines=` the bound is the ceiling itself, and on tmux before 3.7
// a live pane can hold more than a lowered ceiling, which no request can
// read, so calling that a recoverable cut would offer a button that can
// never return more.
// `historyLinesCut` tells the client this happened, apart from any byte
// cut below: `fullSize` counts only what tmux returned, so the bytes a
// later `tail` cut are not all that is left, and the client must not name
// them as the remainder.
const capturedHistoryLines = captureOpts.capturedHistoryLines;
const historyLinesCut =
isFullCapture &&
captureOpts.historyLimitLines !== undefined &&
captureOpts.historyLimitLines < tmuxHistoryLimit &&
capturedHistoryLines !== undefined &&
capturedHistoryLines > captureOpts.historyLimitLines;
if (historyLinesCut) {
truncated = true;
truncationReason = 'tail';
}
// Cap the payload EARLY — before the regex normalization passes below run
// over it. A full-history tmux capture can be tens of MB of scrollback;
// normalizing all of it would stall the event loop only to discard most
@@ -3256,6 +3379,9 @@ export function registerSessionRoutes(
fullSize,
truncated,
truncationReason,
// The `lines=` bound left older lines in tmux (see above): the remainder
// is more than `fullSize - retainedBytes`, by an amount unknown here.
historyLinesCut,
// `retainedBytes` is what this response actually carries; `fullSize` is
// what existed before the cut. The gap is what the indicator reports.
retainedBytes: cleanBuffer.length,
@@ -3279,6 +3405,15 @@ export function registerSessionRoutes(
// damage that does not exist.
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
// Rows tmux holds above the visible frame, which is the most a `full=1`
// pull can add. `truncated` measures the BYTE stream, and for a pane that
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
// a tail cut drops are old repaints that no request can bring back, so a
// client must not offer to load them. Absent when the pane was not read,
// and for a remote session: its local pane hosts the remote tmux client in
// the alternate screen, so it always reports 0, while a `full=1` load reads
// the real scrollback from the remote tmux (`fetchRemoteScrollback`).
paneHistoryLines: hasLiveMuxBuffer && !session.remote ? captureOpts.capturedHistoryLines : undefined,
};
});
@@ -3539,6 +3674,7 @@ export function registerSessionRoutes(
parentSessionId,
agentOrigin,
customModel,
tabGroupId,
} = parseBody(QuickStartSchema, req.body);
// Resolved ONCE here: the same value labels a case directory this request creates
@@ -4193,7 +4329,7 @@ export function registerSessionRoutes(
}
}
await ctx.addSession(session);
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -4279,6 +4415,7 @@ export function registerSessionRoutes(
casePath: resolvedCasePath,
caseName,
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
@@ -5209,6 +5346,17 @@ export function registerSessionRoutes(
// ═══════════════════════════════════════════════════════════════
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
// Single-sourced from the attachment registry (what the file viewer can also play),
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
// would upload but not preview, and a phone's camera roll produces neither.
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
const VIDEO_MIME_EXT: Record<string, string> = {
mp4: '.mp4',
quicktime: '.mov',
'x-m4v': '.m4v',
webm: '.webm',
ogg: '.ogv',
};
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
@@ -5244,13 +5392,23 @@ export function registerSessionRoutes(
}
const { id } = req.params as { id: string };
// `?kind=video` selects the disk-streamed path (see below). It is read here so the
// rate check draws on the right bucket before any work: videos get 3/min, images 30.
const isVideo = (req.query as { kind?: string }).kind === 'video';
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
// — even an authenticated attacker can otherwise loop large image POSTs.
if (!consumePasteToken(`${req.ip}:${id}`)) {
// Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
// authenticated client can otherwise loop large uploads. Videos get their own,
// far lower bucket because the per-file cap is 1GB (see the bucket constants).
const rateKey = `${req.ip}:${id}`;
if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
reply.code(429);
reply.header('Retry-After', '60');
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
isVideo
? 'Rate limit exceeded (3 video uploads/min per session)'
: 'Rate limit exceeded (30 uploads/min per session)'
);
}
const session = findSessionOrFail(ctx, id, req);
@@ -5269,6 +5427,20 @@ export function registerSessionRoutes(
);
}
// The upload is written under session.workingDir (ensureUploadDir below), and
// a live session's workspace can sit on a network mount that stopped answering.
// Probe it bounded before the first disk touch, as #516 requires for a path the
// user named, so one dead mount cannot freeze a libuv threadpool worker per
// upload. Only "unknown" (the mount is not answering) is refused here, the way
// the create route does; absent / not-a-directory keep ensureUploadDir's own
// errors, since a live session's workspace should be neither.
if ((await probePathKind(session.workingDir, { pastCap: true })) === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Workspace', session.workingDir, { pastCap: true })
);
}
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -5280,9 +5452,17 @@ export function registerSessionRoutes(
// boundary scanner with several bugs: literal boundary matches anywhere in
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
// offsets), no part-count cap.
// `?kind=video` (read above for the rate check) is the ONLY way onto the video
// path: it selects the larger, disk-streamed budget before the part is seen
// (busboy takes its limits up front), and it admits video types alone, so an
// image can never ride the video cap through memory. Without it the route is
// byte-identical to the image-only version. Truncation is checked on the stream
// below rather than thrown, since the file is being written as it arrives.
let part: import('@fastify/multipart').MultipartFile | undefined;
try {
part = await req.file();
part = await req.file(
isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined
);
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
@@ -5295,6 +5475,75 @@ export function registerSessionRoutes(
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
}
if (isVideo) {
let ext = '';
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_VIDEO_EXTS.has(origExt)) ext = origExt;
}
const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
if (videoMime) ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
if (!ext) {
await finished(part.file.resume()).catch(() => {});
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
);
}
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
await finished(part.file.resume()).catch(() => {});
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
// Written as it arrives: a phone video is far too large to buffer the way an
// image is. The container is judged on the first 12 bytes as soon as they are
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
// of the body is drained unwritten (as busboy does past its own size limit),
// which is what lets the answer reach the client. A transfer that dies midway
// (the phone left the network) leaves no partial file behind.
const head = Buffer.alloc(12);
let headLen = 0;
let mislabeled = false;
try {
for await (const chunk of part.file) {
if (mislabeled) continue;
if (headLen < head.length) {
headLen += chunk.copy(head, headLen, 0, head.length - headLen);
mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
if (mislabeled) continue;
}
// writeFile loops until the whole chunk is on disk; a bare write() may be short.
await fh.writeFile(chunk);
}
} catch (err: unknown) {
await fs.unlink(filepath).catch(() => {});
throw err;
} finally {
await fh.close();
}
let failure: { status: number; message: string } | null = null;
if (headLen === 0) failure = { status: 400, message: 'Empty file' };
else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
console.warn(
`[paste-image] video magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${head.subarray(0, headLen).toString('hex')}`
);
failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
} else if (part.file.truncated) {
const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
}
if (failure) {
await fs.unlink(filepath).catch(() => {});
reply.code(failure.status);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
}
return { path: filepath, filename };
}
let imageBytes: Buffer;
try {
imageBytes = await part.toBuffer();
@@ -5371,16 +5620,7 @@ export function registerSessionRoutes(
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
const filepath = join(imageDir, filename);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
const fh = await fs.open(
filepath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
);
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
try {
await fh.writeFile(imageBytes);
} finally {
+17 -1
View File
@@ -20,13 +20,14 @@ import { FastifyInstance } from 'fastify';
import { StatusTelemetrySchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import {
parsePromptCache,
parseStatusTelemetry,
parseSessionStatus,
formatSessionStatusText,
telemetrySignature,
type RawStatuslinePayload,
} from '../../usage-telemetry.js';
import { SessionStatusTelemetry } from '../sse-events.js';
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/index.js';
@@ -59,6 +60,21 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
session.noteReportedModel('statusline', payload?.model?.display_name);
}
// Prompt-cache state of the main conversation (Claude Code v2.1.251+): kept on the
// session for the Session Options readout and `toState()`, broadcast only when the
// report changes (the statusline fires on every assistant message). The broadcast
// carries the STORED object, so a compaction stamp set in setPromptCache rides along.
// Reports are applied in arrival order. When the exporter wraps a user's own statusline
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = parsePromptCache(payload);
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
+67
View File
@@ -127,6 +127,24 @@ export const FileWriteSchema = z
})
.strict();
/**
* Body validation for `POST /api/sessions/:id/html-view` (render a local HTML file
* as a sandboxed page). `path` is absolute or relative to the session workspace;
* the extension, containment, guard and broad-root checks are the route's
* (html-view-routes.ts), after parsing.
*/
export const HtmlViewOpenSchema = z
.object({
path: z
.string({ error: 'Missing path parameter' })
.min(1, 'Missing path parameter')
.max(4096)
.refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
message: 'Invalid path',
}),
})
.strict();
/**
* The run-mode ids the API currently accepts: every ENABLED registry entry.
*
@@ -533,12 +551,23 @@ export const DeepSeekWebStartSchema = z
*/
const parentSessionIdSchema = z.string().max(100).optional();
/**
* Tab group the new session should be placed in (the group's "New session"
* action). Advisory like parentSessionId: the layout service looks the id up in
* the creating owner's own layout and ignores one it cannot find, so a stale or
* foreign id never fails a spawn. A malformed value (null, empty, over-long) is
* still a 400, because only the browser sends it.
*/
const tabGroupIdSchema = z.string().min(1).max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: sessionModeSchema().optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -653,6 +682,34 @@ export const StatusTelemetrySchema = z.object({
.nullish(),
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
model: z.object({ display_name: z.string().max(100).nullish() }).nullish(),
// Main-conversation prompt-cache statistics (Claude Code v2.1.251+). Every documented
// scalar field is listed nullish: `expires_at`/`hit_ratio`/`last_miss_at`/
// `recache_tokens_if_cold` are all documented as possibly null, and the object gains
// fields over CLI versions, so a stricter shape would 400 the whole post and blank the
// footer (the recurring .optional()/null trap). Causes stay `unknown` (the parser keeps
// the strings); `last_miss_cause`'s extra numeric keys and the `miss_causes` record are
// stripped by Zod unread, which keeps the stored object bounded — a hostile localhost
// payload cannot bloat state, since only the normalized subset below is ever stored.
prompt_cache: z
.object({
warm: z.boolean().nullish(),
caching_observed: z.boolean().nullish(),
// No length cap: a future TTL longer than today's `5m`/`1h` must not 400 the whole
// post and blank the footer. parsePromptCache discards any TTL it does not know, so
// the stored value stays bounded to those two regardless.
ttl: z.string().nullish(),
expires_at: z.number().nullish(),
requests: z.number().nullish(),
misses: z.number().nullish(),
expected_rebuilds: z.number().nullish(),
hit_ratio: z.number().nullish(),
cache_write_tokens: z.number().nullish(),
miss_recache_tokens: z.number().nullish(),
last_miss_at: z.number().nullish(),
recache_tokens_if_cold: z.number().nullish(),
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
})
.nullish(),
})
.nullish(),
});
@@ -1080,6 +1137,8 @@ export const QuickStartSchema = z.object({
sessionName: z.string().max(128).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
@@ -1348,6 +1407,14 @@ export const SettingsUpdateSchema = z
* broadcast and /api/search, which is the user's choice to make.
*/
autoNameSessions: z.boolean().optional(),
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
* hand. SYNCED, default OFF, because the server does the placement:
* TabLayoutService.sessionCreated() reads it at every creation, and with it
* off the child lands after its parent once and is hand-placed from then on.
*/
spawnedTabsFollowParent: z.boolean().optional(),
/**
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
+64 -13
View File
@@ -59,7 +59,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js';
import { TabLayoutService } from '../tab-layout-service.js';
import { TabLayoutService, type SessionPlacementHint } from '../tab-layout-service.js';
import type { TabLayout } from '../tab-layout.js';
import { ownerLayoutKey } from '../tab-layout-persistence.js';
import { readWebviews } from '../webview-store.js';
import { extractCompletionPhrase } from '../ralph-config.js';
@@ -75,6 +76,7 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts } from '../custom-model-hosts.js';
@@ -158,6 +160,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
@@ -178,6 +181,7 @@ import {
registerTeamRoutes,
registerMuxRoutes,
registerFileRoutes,
registerHtmlViewRoutes,
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
@@ -432,9 +436,14 @@ export class WebServer extends EventEmitter {
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(stripBasePath(this.basePath, req.url || ''));
if (https) {
const { key, cert } = getOrCreateSelfSignedCert();
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl });
this.app = Fastify({
logger: false,
https: { key, cert },
rewriteUrl,
routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH },
});
} else {
this.app = Fastify({ logger: false, rewriteUrl });
this.app = Fastify({ logger: false, rewriteUrl, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } });
}
this.mux = createMultiplexer();
this.sse = new SseStreamManager(
@@ -456,6 +465,9 @@ export class WebServer extends EventEmitter {
this.cachedLightState = null;
this.sse.broadcastSessionOrder(change);
},
// Fresh, not the 2s cache: the settings PUT does not invalidate it, and a
// flip must apply to the very next spawn.
childrenFollowParent: async () => (await this.readSettings(true)).spawnedTabsFollowParent === true,
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
@@ -708,15 +720,19 @@ export class WebServer extends EventEmitter {
}
}
/** Add a tentative session only after its owner layout accepts the creation. */
private async registerSessionWithLayout(session: Session): Promise<void> {
this.sessions.set(session.id, session);
try {
await this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner));
} catch (error) {
this.sessions.delete(session.id);
throw error;
}
/**
* Add a tentative session only after its owner layout accepts the creation.
* The session enters the live map inside the layout service's owner lock, so
* the placement it asked for (`tabGroupId`) lands in the same versioned write
* that first records it.
*/
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
this.sessions.set(session.id, session);
return () => {
if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
};
});
}
/**
@@ -1127,6 +1143,7 @@ export class WebServer extends EventEmitter {
registerTeamRoutes(this.app, ctx);
registerMuxRoutes(this.app, ctx);
registerFileRoutes(this.app, ctx);
registerHtmlViewRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);
@@ -1526,6 +1543,7 @@ export class WebServer extends EventEmitter {
fileStreamManager.closeSessionStreams(sessionId);
// Drop live external attachment registrations for this session
attachmentRegistry.clearSession(sessionId);
folderGrants.clearSession(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session. The dir belongs to the
@@ -1783,6 +1801,19 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
);
// Which run modes page their own transcript on PageUp/PageDown, for the scroll
// fallback over a pane with no local history (`_maybePageCliTranscript`). Read off
// the `transcriptPageKeys` capability, never an id list here, and injected for a
// solo window too for the same reason as the gutter map: it scrolls a terminal.
// Ids only, which the registry schema bounds to kebab tokens, so JSON.stringify
// alone is enough.
const pageKeyClis = enabledClis()
.filter((entry) => entry.capabilities.transcriptPageKeys === true)
.map((entry) => entry.id);
html = html.replace(
'</head>',
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
@@ -2742,9 +2773,12 @@ export class WebServer extends EventEmitter {
// Approvals Inbox item id: lets sw.js answer an Approve/Deny action
// click directly (POST /api/approvals/:id/answer) with no tab open.
// Gated on the opt-in setting together with the action buttons.
// Approve/Deny only when we also have an approvalId to answer with.
// Supervised-hook CLIs (DeepSeek) omit approvalId upstream so this also
// strips their action buttons — buttons without an id can never answer.
approvalId: approvalsEnabled && typeof data.approvalId === 'string' ? data.approvalId : undefined,
urgency: template.urgency,
actions: approvalsEnabled ? template.actions : undefined,
actions: approvalsEnabled && typeof data.approvalId === 'string' ? template.actions : undefined,
});
for (const sub of subscriptions) {
@@ -3423,6 +3457,7 @@ export class WebServer extends EventEmitter {
// otherwise meet the retry, which reuses the same session id by design.
this.stopTranscriptWatcher(sessionId);
attachmentRegistry.clearSession(sessionId);
folderGrants.clearSession(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
@@ -3569,6 +3604,11 @@ export class WebServer extends EventEmitter {
// tile header keeps naming it until the next report; an idle claude
// session re-renders its statusline only on its next turn.
displayModel: savedState?.displayModel,
// The last prompt-cache report, restored for the same reason: an idle claude
// session re-renders its statusline only on its next turn, so without this the
// Session Options readout would read "not reported yet" after every restart. The
// readout is time-aware, so a value past its expiry shows as cold, not stale-warm.
promptCache: savedState?.promptCache,
// A record rebuilt from the socket has no provenance, so its
// apparent locality is a guess (see `MuxSession.discovered`).
discoveredMuxSession: muxSession.discovered,
@@ -3682,6 +3722,17 @@ export class WebServer extends EventEmitter {
if (savedState.flickerFilterEnabled !== undefined) {
session.flickerFilterEnabled = savedState.flickerFilterEnabled;
}
// Tab colour, pin and the image watcher are user-set and persisted like the settings
// above, but this path never read them back: after a restart every surviving pane came
// back uncoloured and unpinned with its image watcher off, and the next persist
// overwrote the saved values. `reapplyPersistedSessionState()` (the reboot-restore
// path) already restores all three. The watcher flag has to be set before
// `setupSessionListeners()` below, which reads it to decide whether to start one.
if (savedState.color) session.setColor(savedState.color);
if (savedState.pinned) session.restorePin(true, savedState.pinnedAt);
if (savedState.imageWatcherEnabled !== undefined) {
session.imageWatcherEnabled = savedState.imageWatcherEnabled;
}
// Respawn controller (not supported for external-CLI sessions)
if (!isExternalCliMode(session.mode) && savedState.respawnEnabled && savedState.respawnConfig) {
try {
+5 -2
View File
@@ -5,10 +5,10 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 161 event constants organized by category:
* 162 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
* - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
@@ -119,6 +119,8 @@ export const SessionInteractive = 'session:interactive' as const;
export const SessionRunning = 'session:running' as const;
/** Combined Claude and main Codex plan-usage telemetry for the shared header chip. */
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */
export const SessionPromptCache = 'session:promptCache' as const;
// ─── Session: Ralph ──────────────────────────────────────────────────────────
@@ -526,6 +528,7 @@ export const SseEvent = {
SessionInteractive,
SessionRunning,
SessionStatusTelemetry,
SessionPromptCache,
// Session: Ralph
SessionRalphLoopUpdate,
+11
View File
@@ -660,6 +660,13 @@ describe('httpRequest', () => {
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
return;
}
if (req.url?.startsWith('/reset')) {
// Headers and half a body, then the connection drops (a restart mid-answer).
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write('{"success":true,"data":');
setTimeout(() => req.socket.destroy(), 50);
return;
}
res
.writeHead(200, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: true, data: { echo: true } }));
@@ -702,6 +709,10 @@ describe('httpRequest', () => {
expect(last.headers.authorization).toBeUndefined();
});
it('rejects, rather than hanging, when the connection drops mid-body', async () => {
await expect(httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/reset', timeoutMs: 5000 })).rejects.toThrow();
});
it('keeps a plain-text body when the answer is not JSON', async () => {
const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
expect(res.status).toBe(401);
-12
View File
@@ -124,10 +124,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
1,
'an error message explaining why THIS mode in particular will never deliver a stop signal'
),
"web/routes/approval-routes.ts::mode === 'deepseek'": allow(
1,
'the DeepSeek status bridge is the only non-claude source of approval items'
),
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
"web/routes/session-routes.ts::mode === 'claude'": allow(
@@ -169,14 +165,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow(
1,
'push buttons withheld where the answer route refuses keystrokes'
),
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": allow(
+20
View File
@@ -414,4 +414,24 @@ describe('capability shapes', () => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing');
});
it('takes transcriptPageKeys as an optional boolean, absent meaning no paging', () => {
for (const value of [true, false]) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).transcriptPageKeys = value;
expect(CliEntrySchema.safeParse(entry).success, `transcriptPageKeys=${value}`).toBe(true);
}
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).transcriptPageKeys;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcriptPageKeys = 'yes';
}, 'transcriptPageKeys is a boolean');
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex and opencode (both on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
});
});
+365
View File
@@ -0,0 +1,365 @@
/**
* @fileoverview Clickable file paths beyond the rooted absolute form: `~/…`,
* relative paths, and paths a program broke over lines itself.
*
* - `findFilePathLinks` / `resolveLinkedFilePath` / `terminalPathLine`
* (constants.js) on the shapes real agent output has: Claude Code echoes an
* attached image as `› [image] ~/…/garage.png`, scripts print
* `builds/captures/…png`, and Claude Code wraps tool output a few columns
* short of the edge, so a long path arrives as `…/captures/2` + `026-10-10/x.png`.
* - The terminal link provider turns those into ONE link and opens the resolved
* path (relative against the session's working directory, `~/` as is).
* - The server expands `~/` (local: this host's home; remote: the probe on the
* remote host), so the attachment route can serve it.
*
* Real code under test: constants.js + terminal-ui.js in a `vm` context, and
* the shipped attachment registry / probe script.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { registerExternalAttachment } from '../src/attachment-registry.js';
import { buildRemoteProbeCommand, parseRemoteProbeOutput } from '../src/remote-files.js';
type Line = {
startRow: number;
endRow: number;
text: string;
offsetToCell: (o: number) => { row: number; col: number };
};
type Helpers = {
findFilePathLinks: (text: string) => Array<{ path: string; index: number }>;
resolveLinkedFilePath: (path: string, workingDir?: string) => string;
terminalPathLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => Line | null;
};
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
function loadHelpers(): Helpers {
const context = vm.createContext({});
vm.runInContext(
`${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath, terminalPathLine };`,
context,
{ filename: 'constants.js' }
);
return (context as { __h: Helpers }).__h;
}
const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpers();
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */
function buffer(lines: string[], cols = 80) {
return {
length: lines.length,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: false,
translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)),
}
: undefined,
};
}
// Verbatim from a live pane (2026-10-10): the tool output wrapped at ~64 columns.
const BROKEN = [
' ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2',
' 026-10-10/ghost1b_gameplay.png err=0 kamera=(1108,13,811',
' ) blick=',
];
const BROKEN_PATH = '/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/ghost1b_gameplay.png';
describe('findFilePathLinks', () => {
it('links home-relative and relative paths', () => {
expect(paths(' › [image] ~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png (1.1MB)')).toEqual([
'~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png',
]);
expect(paths('2 Bericht: refs/shaderpatch_pixel_1010/BERICHT.md. Alle 12')).toEqual([
'refs/shaderpatch_pixel_1010/BERICHT.md',
]);
expect(paths('./a.md ../b.json look_montage.png')).toEqual(['./a.md', '../b.json', 'look_montage.png']);
});
it('keeps brackets, `=` and trailing punctuation out of a relative path', () => {
expect(paths('1. Garage-BG (garage_plate.png) — Bühne')).toEqual(['garage_plate.png']);
expect(paths('OUT=builds/captures/x.png, "shots/b.webp"')).toEqual(['builds/captures/x.png', 'shots/b.webp']);
expect(paths('see src/a.ts:120.')).toEqual(['src/a.ts']);
});
it("keeps a shell prompt's trailing `$` or `#` out of the folder it links", () => {
expect(paths('arkon@tnode:~/default/claudeman$ ls')).toEqual(['~/default/claudeman']);
expect(paths('root@c:/workspace/app# ls')).toEqual(['/workspace/app']);
});
it('still finds rooted absolute paths, and never a relative path inside one', () => {
expect(paths('sign-off from /mnt/raid1/x/refs/look_montage.png.')).toEqual(['/mnt/raid1/x/refs/look_montage.png']);
// Unknown root: not linked at all (no `nix/store/…` cut out of it).
expect(paths('/nix/store/abc-x/share/icon.png')).toEqual([]);
// `/etc` stays out on purpose (the server refuses that tree).
expect(paths('/etc/app/config.json')).toEqual([]);
});
it('never cuts a path out of a web URL, but keeps file:// paths', () => {
expect(paths('https://x.io/a/home/b.png?img=c.png')).toEqual([]);
expect(paths('file:///home/joe/x.png')).toEqual(['/home/joe/x.png']);
});
it('reports where each path starts', () => {
const text = 'a ~/x.png b /tmp/y.pdf c z.md';
for (const { path, index } of findFilePathLinks(text)) expect(text.slice(index, index + path.length)).toBe(path);
});
});
describe('resolveLinkedFilePath', () => {
it('resolves relative paths against the working directory, collapsing . and ..', () => {
expect(resolveLinkedFilePath('builds/x.png', '/mnt/ws')).toBe('/mnt/ws/builds/x.png');
expect(resolveLinkedFilePath('./a/./b.md', '/mnt/ws/')).toBe('/mnt/ws/a/b.md');
expect(resolveLinkedFilePath('../other/x.png', '/mnt/ws')).toBe('/mnt/other/x.png');
});
it('leaves absolute and ~/ paths alone, and relative ones without a working directory', () => {
expect(resolveLinkedFilePath('/tmp/a.png', '/mnt/ws')).toBe('/tmp/a.png');
expect(resolveLinkedFilePath('~/a.png', '/mnt/ws')).toBe('~/a.png');
expect(resolveLinkedFilePath('a.png', undefined)).toBe('a.png');
});
});
describe('terminalPathLine', () => {
it('glues a path broken short of the edge back together, from either row', () => {
for (const row of [0, 1]) {
const line = terminalPathLine(buffer(BROKEN), row, 80)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
const [found] = findFilePathLinks(line.text);
expect(found.path).toBe(BROKEN_PATH);
// The cells are exact on both rows: after ` ⎿ BILD ` and after the 5-column indent.
expect(line.offsetToCell(found.index)).toEqual({ row: 0, col: 10 });
expect(line.offsetToCell(found.index + found.path.length - 1)).toEqual({ row: 1, col: 34 });
}
expect(terminalPathLine(buffer(BROKEN), 2, 80)).toMatchObject({ startRow: 2, endRow: 2 });
});
it('follows a path over three rows', () => {
const rows = ['x /tmp/claude-1001/-home-joe-cases', ' -neon/de4576ce-aaaaaaaaaaaaa', ' -56b4/shot.png'];
const line = terminalPathLine(buffer(rows), 1, 80)!;
expect(paths(line.text)).toEqual(['/tmp/claude-1001/-home-joe-cases-neon/de4576ce-aaaaaaaaaaaaa-56b4/shot.png']);
});
it('does not glue lines that are not a broken path', () => {
// A shorter line ending on a directory, then a longer one: not a wrap.
expect(terminalPathLine(buffer(['cd /mnt/raid1/foo', ' file.png erstellt']), 0, 80)).toMatchObject({ endRow: 0 });
// A chain that never reaches a file is dropped.
const listing = terminalPathLine(buffer(['cd ~/repos/neon', 'ls', 'a.png']), 2, 80)!;
expect(paths(listing.text)).toEqual(['a.png']);
// A path that already ended, and a next line opening a new rooted path.
expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 });
});
// `git ls-files`, `find` and `ls -R` print one path per row. An extensionless
// entry (a directory, `Dockerfile`, `.gitignore`) followed by an entry that is
// equal or shorter looked exactly like a path broken short of the edge, and the
// two were glued: `src/components` + `src/index.ts` linked `src/componentssrc/index.ts`
// and the real `src/index.ts` link was gone (Ark0N, PR #604, round 2).
describe('a listing is never glued into one path', () => {
const LISTING = [
'src/components',
'src/index.ts',
'src/lib',
'./index.ts',
'../docs/README.md',
'packages/gesture-control/.gitignore',
'packages/gesture-control/README.md',
'packages/gesture-control/package.json',
'data/runs/a.jsonl',
'data/runs/b.jsonl',
'data/runs/c.jsonl',
'data/runs/d.json',
];
it('keeps every row its own line', () => {
const buf = buffer(LISTING, 120);
LISTING.forEach((entry, row) => {
const line = terminalPathLine(buf, row, 120)!;
expect([line.startRow, line.endRow], entry).toEqual([row, row]);
expect(line.text, entry).toBe(entry);
});
});
it('links the entry that used to be swallowed', () => {
const buf = buffer(LISTING, 120);
expect(paths(terminalPathLine(buf, 1, 120)!.text)).toEqual(['src/index.ts']);
});
it('still glues a path whose next row opens on a different directory (a real wrap)', () => {
// The control: `…/captures/2` + `026-10-10/…` has a `/` in its head too.
const line = terminalPathLine(buffer(BROKEN), 0, 80)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
expect(paths(line.text)).toEqual([BROKEN_PATH]);
});
});
});
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (ev: unknown, text: string) => void;
};
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
function loadApp() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
return app;
}
function linksOn(lines: string[], row: number) {
const app = loadApp();
app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } };
const provider = app.registerFilePathLinkProvider() as Provider;
let out: Link[] = [];
provider.provideLinks(row, (links) => {
out = links || [];
});
return { app, links: out };
}
describe('terminal link provider', () => {
it('links a broken path once, across both rows, and opens the whole path', () => {
const { app, links } = linksOn(BROKEN, 2);
expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]);
expect(links[0].range.start).toEqual({ x: 11, y: 1 });
expect(links[0].range.end.y).toBe(2);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(BROKEN_PATH, 's1');
});
it('opens a relative path resolved against the working directory', () => {
const { app, links } = linksOn([' ⎿ wrote builds/captures/2026-10-10/a.png'], 1);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(
'/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/a.png',
's1'
);
});
it('opens a ~/ path as is, in the preview (the server expands it), even for text', () => {
const { app, links } = linksOn([' › [image] ~/repos/x/garage.png', 'notes ~/logs/run.log'], 2);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith('~/logs/run.log', 's1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('opens a relative text file in the log viewer by its resolved path', () => {
const { app, links } = linksOn(['tail logs/run.log'], 1);
links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/build/neon_getaway-mgr-hebel/logs/run.log', 's1');
});
it('routes .md/.markdown and .html/.htm in every link form to the preview, other text to the log viewer', () => {
// The rendered markdown viewer and the HTML page view (rendersAsPage) apply to
// `~/`, relative and hard-wrapped links exactly as to rooted ones.
const WS = '/mnt/build/neon_getaway-mgr-hebel';
const cases: Array<[string[], number, string, 'preview' | 'log']> = [
[['wrote ~/notes/plan.md'], 1, '~/notes/plan.md', 'preview'],
[['wrote ~/site/index.html'], 1, '~/site/index.html', 'preview'],
[['wrote docs/README.markdown'], 1, `${WS}/docs/README.markdown`, 'preview'],
[['wrote report/index.htm'], 1, `${WS}/report/index.htm`, 'preview'],
[['wrote ./notes/plan.md'], 1, `${WS}/notes/plan.md`, 'preview'],
[['wrote out/data.json'], 1, `${WS}/out/data.json`, 'log'],
[
[' ⎿ WROTE /mnt/build/neon_getaway-mgr-hebel/builds/reports/2', ' 026-10-10/summary.md done', ''],
2,
`${WS}/builds/reports/2026-10-10/summary.md`,
'preview',
],
[
[' ⎿ WROTE /mnt/build/neon_getaway-mgr-hebel/builds/reports/2', ' 026-10-10/page.html done', ''],
2,
`${WS}/builds/reports/2026-10-10/page.html`,
'preview',
],
];
for (const [lines, row, expected, where] of cases) {
const { app, links } = linksOn(lines, row);
expect(links.length, expected).toBe(1);
links[0].activate({}, links[0].text);
if (where === 'preview') {
expect(app.openFilePreview, expected).toHaveBeenCalledWith(expected, 's1');
expect(app.openLogViewerWindow, expected).not.toHaveBeenCalled();
} else {
expect(app.openLogViewerWindow, expected).toHaveBeenCalledWith(expected, 's1');
expect(app.openFilePreview, expected).not.toHaveBeenCalled();
}
}
});
it('never adds a path link overlapping a URL link', () => {
const { links } = linksOn(['see https://x.io/view?img=shots/a.png now'], 1);
expect(links.map((l) => l.text)).toEqual(['https://x.io/view?img=shots/a.png']);
});
});
describe('server: ~/ expands on the session host', () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-tilde-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
it('a local session registers ~/… against this host home', async () => {
// test/setup.ts points HOME at a temp dir, so this never touches the real home.
const dir = join(homedir(), 'tilde-link-test');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'shot.png'), 'PNG');
try {
const event = await registerExternalAttachment('tilde-session', '~/tilde-link-test/shot.png', {});
expect(event.attachmentId).toMatch(/^att_/);
expect(event.size).toBe(3);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('a relative path is still refused', async () => {
await expect(registerExternalAttachment('tilde-session', 'shots/a.png', {})).rejects.toThrow(/absolute/);
});
it('the remote probe expands ~/ to the remote $HOME, and reports the absolute path', () => {
writeFileSync(join(root, 'remote.png'), 'PNGPNG');
const requested = ['~/remote.png', '~/missing.png'];
const stdout = execFileSync('sh', ['-c', buildRemoteProbeCommand(requested)], {
env: { ...process.env, HOME: root },
}).toString();
const [found, missing] = parseRemoteProbeOutput(stdout, requested);
expect(found).toMatchObject({ kind: 'file', size: 6 });
expect(found?.realPath.endsWith('/remote.png')).toBe(true);
expect(found?.realPath.startsWith('/')).toBe(true);
expect(missing).toBeNull();
});
});
+2
View File
@@ -40,6 +40,8 @@ function loadApp() {
escapeHtml: (s: string) => String(s),
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
CodemanBase: { base: '', url: (p: string) => p },
// Also from constants.js: no file in this suite renders as an HTML page.
rendersAsPage: () => false,
document: { getElementById: () => null, addEventListener: vi.fn() },
window: windowStub,
setTimeout,
+217
View File
@@ -0,0 +1,217 @@
/**
* @fileoverview File Viewer: an `.html` file renders as a sandboxed page.
*
* The server half (`/html-view/<cap>/...`, html-view-routes.ts) is pinned by
* test/routes/html-view-routes.test.ts. This file pins the frontend half:
*
* 1. `rendersAsPage()` (constants.js) and the server's HTML_VIEW_EXTENSIONS
* name the same extensions, so the terminal, the preview and the route
* agree on what a page is.
* 2. A successful mint renders an iframe whose `sandbox` carries scripts but
* never `allow-same-origin` (with it, the page could drive Codeman), with
* the Page pill shown and pressed.
* 3. A refused mint (remote case, broad folder, hidden directory) falls
* through to the source view with its Edit button, and hides the pill,
* which would only retry a page the server just refused.
* 4. The pill flips between page and source and persists
* `codeman:filePreviewHtmlRendered` per device; with it off the preview
* opens as source without asking the server for a page at all.
*
* Loaded via `vm` with a jsdom document injected, like
* file-preview-markdown.test.ts: constants.js + panels-ui.js only.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { HTML_VIEW_EXTENSIONS } from '../src/web/routes/html-view-routes.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
// A real origin: jsdom's localStorage getter throws on an opaque one.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
const PAGE_PATH = '/home/u/cases/demo/report/index.html';
const PAGE_URL = '/html-view/AAAAAAAAAAAAAAAAAAAAAAAA/index.html';
const HTML_SOURCE = '<!doctype html>\n<h1>Report</h1>\n';
function jsonResponse(body: unknown, status = 200) {
return { ok: status < 400, status, json: async () => body, text: async () => JSON.stringify(body) };
}
/**
* The two routes the preview talks to for an HTML file: the mint (answered or
* refused per `mint`) and file-content, which serves the source view.
*/
function makeFetch(mint: 'ok' | 'refuse') {
return vi.fn(async (url: string, init?: { method?: string; body?: string }) => {
if (url.endsWith('/html-view')) {
return mint === 'ok'
? jsonResponse({ success: true, data: { url: PAGE_URL } })
: jsonResponse({ success: false, error: 'This folder is too broad to render a page from' }, 403);
}
if (url.includes('/file-content')) {
return jsonResponse({
success: true,
data: { content: HTML_SOURCE, totalLines: 3, size: HTML_SOURCE.length, truncated: false, editable: true },
});
}
throw new Error(`unexpected fetch ${init?.method || 'GET'} ${url}`);
});
}
function loadApp(mint: 'ok' | 'refuse', prefs: Record<string, string> = {}) {
const store = new Map(Object.entries(prefs));
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const fetchMock = makeFetch(mint);
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
MobileDetection: {},
setTimeout,
clearTimeout,
confirm: () => true,
fetch: fetchMock,
});
vm.runInContext(`${constantsJs}\n${panelsJs}`, context, { filename: 'panels-ui.js' });
// The overlay's elements, as index.html ships them.
document.body.innerHTML = `
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
<button id="filePreviewMdBtn" hidden></button>
<button id="filePreviewPageBtn" hidden></button>
<button id="filePreviewLinesBtn" hidden></button>
<button id="filePreviewWrapBtn" hidden></button>
<button id="filePreviewEditBtn" hidden></button>
<button id="filePreviewDetachBtn" hidden></button>
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app._resetFilePreviewEdit = () => {
const edit = document.getElementById('filePreviewEditBtn');
if (edit) edit.hidden = true;
};
app._isExternalPreviewPath = () => false;
app.formatFileSize = (n: number) => `${n} B`;
app.filePreviewContent = '';
const byId = (id: string) => document.getElementById(id) as HTMLElement;
return {
app,
fetchMock,
store,
body: byId('filePreviewBody'),
footer: byId('filePreviewFooter'),
page: byId('filePreviewPageBtn'),
edit: byId('filePreviewEditBtn'),
};
}
/** The mint requests the stub saw, as parsed bodies. */
function mintCalls(fetchMock: ReturnType<typeof makeFetch>) {
return fetchMock.mock.calls
.filter(([url]) => url.endsWith('/html-view'))
.map(([url, init]) => ({ url, method: init?.method, body: JSON.parse(init?.body || '{}') }));
}
describe('rendersAsPage', () => {
it('names exactly the extensions the html-view route renders', () => {
const context = vm.createContext({});
vm.runInContext(`${constantsJs}\nglobalThis.__page = { rendersAsPage, HTML_PAGE_EXTENSIONS };`, context, {
filename: 'constants.js',
});
const { rendersAsPage, HTML_PAGE_EXTENSIONS } = (
context as { __page: { rendersAsPage: (p: string) => boolean; HTML_PAGE_EXTENSIONS: Set<string> } }
).__page;
expect([...HTML_PAGE_EXTENSIONS].sort()).toEqual([...HTML_VIEW_EXTENSIONS].sort());
expect(rendersAsPage('/tmp/out/report.html')).toBe(true);
expect(rendersAsPage('docs/INDEX.HTM')).toBe(true);
expect(rendersAsPage('notes.md')).toBe(false);
expect(rendersAsPage('page.html.txt')).toBe(false);
expect(rendersAsPage(undefined as unknown as string)).toBe(false);
});
});
describe('file viewer HTML page', () => {
it('renders a minted page in a sandboxed iframe that never gets allow-same-origin', async () => {
const { app, fetchMock, body, footer, page, edit } = loadApp('ok');
await app.openFilePreview(PAGE_PATH, 's1');
expect(mintCalls(fetchMock)).toEqual([
{ url: '/api/sessions/s1/html-view', method: 'POST', body: { path: PAGE_PATH } },
]);
const frame = body.querySelector('iframe')!;
expect(frame.getAttribute('src')).toBe(PAGE_URL);
const sandbox = (frame.getAttribute('sandbox') || '').split(/\s+/);
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
expect(footer.textContent).toBe('HTML');
expect(app.filePreviewDetachUrl).toBe(PAGE_URL);
expect(page.hidden).toBe(false);
expect(page.getAttribute('aria-pressed')).toBe('true');
// A page has no text for Edit to act on.
expect(edit.hidden).toBe(true);
});
it('falls back to the source view with Edit and no Page pill when the mint is refused', async () => {
const { app, fetchMock, body, page, edit } = loadApp('refuse');
await app.openFilePreview(PAGE_PATH, 's1');
expect(mintCalls(fetchMock)).toHaveLength(1);
expect(body.querySelector('iframe')).toBeNull();
const pre = body.querySelector('pre.file-preview-text')!;
expect(pre.textContent).toBe(HTML_SOURCE);
expect(page.hidden).toBe(true);
expect(edit.hidden).toBe(false);
});
it('flips between page and source with the pill and persists the choice per device', async () => {
const { app, fetchMock, body, page, edit, store } = loadApp('ok');
await app.openFilePreview(PAGE_PATH, 's1');
expect(body.querySelector('iframe')).not.toBeNull();
await app.toggleFilePreviewPage();
expect(store.get('codeman:filePreviewHtmlRendered')).toBe('0');
expect(body.querySelector('iframe')).toBeNull();
expect(body.querySelector('pre.file-preview-text')!.textContent).toBe(HTML_SOURCE);
expect(page.hidden).toBe(false);
expect(page.getAttribute('aria-pressed')).toBe('false');
expect(edit.hidden).toBe(false);
// Source never asks the server for a page.
expect(mintCalls(fetchMock)).toHaveLength(1);
await app.toggleFilePreviewPage();
expect(store.get('codeman:filePreviewHtmlRendered')).toBe('1');
expect(body.querySelector('iframe')!.getAttribute('src')).toBe(PAGE_URL);
expect(page.getAttribute('aria-pressed')).toBe('true');
expect(mintCalls(fetchMock)).toHaveLength(2);
});
it('opens as source, pill offered, when the device pref says so', async () => {
const { app, fetchMock, body, page } = loadApp('ok', { 'codeman:filePreviewHtmlRendered': '0' });
await app.openFilePreview(PAGE_PATH, 's1');
expect(mintCalls(fetchMock)).toHaveLength(0);
expect(body.querySelector('pre.file-preview-text')!.textContent).toBe(HTML_SOURCE);
expect(page.hidden).toBe(false);
expect(page.getAttribute('aria-pressed')).toBe('false');
});
});
+9 -4
View File
@@ -19,8 +19,10 @@
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
* holds is byte-identical to the file; every toggle is hidden for an image
* and while editing.
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
* (in-workspace text keeps the tail viewer, see architecture-invariants).
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico, and `md`/`markdown` (owner
* decision 2026-10-13: a markdown path clicked in the terminal opens this
* rendered view, not the streaming log viewer), while other in-workspace
* text keeps the tail viewer (see architecture-invariants).
* 6. A preview opened by attachment id under a bare file name has no
* directory to resolve against, so its relative images degrade to alt text
* and its relative links to plain text instead of landing on the workspace
@@ -464,10 +466,13 @@ describe('file viewer markdown line breaks', () => {
});
describe('FILE_PREVIEW_EXTENSIONS', () => {
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
it('routes avif, ico and markdown paths to the viewer and leaves other text with the tail viewer', () => {
const { exts } = loadApp();
expect(exts.has('avif')).toBe(true);
expect(exts.has('ico')).toBe(true);
expect(exts.has('md')).toBe(false);
// Markdown is a document to read rendered, not a log to follow.
expect(exts.has('md')).toBe(true);
expect(exts.has('markdown')).toBe(true);
for (const text of ['log', 'txt', 'json', 'ts', 'yaml', 'csv']) expect(exts.has(text), text).toBe(false);
});
});
+2
View File
@@ -66,6 +66,8 @@ function loadApp(media: FakeMedia[]) {
escapeHtml: (s: string) => String(s),
// Reverse-proxy route builder from constants.js (not loaded here); identity at root.
CodemanBase: { base: '', url: (p: string) => p },
// Also from constants.js: no file in this suite renders as an HTML page.
rendersAsPage: () => false,
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -39,6 +39,7 @@ function loadApp() {
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
+1 -1
View File
@@ -298,7 +298,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
it('finds the overlays it is meant to guard', () => {
// A rename that empties this list would turn every assertion below into a
// no-op, so the count is pinned.
expect(CENTRED_OVERLAYS.length).toBe(7);
expect(CENTRED_OVERLAYS.length).toBe(8);
});
it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => {
+418
View File
@@ -0,0 +1,418 @@
/**
* @fileoverview Folder grid, client side.
*
* - Folder paths are links (`findFilePathLinks` marks them `folder: true`): rooted
* and `~/` paths whose last segment has no extension, relative ones only with a
* trailing slash. File links win any overlap; prose and URLs stay plain text.
* - Both consumers route a folder link to `openFolderGrid`.
* - `openFolderGrid` grants the folder (`POST …/folders`), renders tiles from the
* folder's by-name routes, and falls back to the file preview when the server
* finds a file there.
* - A tile's preview renders from the folder routes (never the attachment
* registry), pages with ‹ › / ←/→ (wrapping), and the arrows are kept away from
* the terminal; "Show folder" opens the grid of a previewed file's folder.
*
* Real code under test: constants.js + panels-ui.js in a `vm` context against a
* stub app and a minimal fake DOM (no jsdom), as in file-preview-detach.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
type Found = { path: string; index: number; folder?: true };
const constants = vm.createContext({});
vm.runInContext(`${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath };`, constants, {
filename: 'constants.js',
});
const { findFilePathLinks, resolveLinkedFilePath } = (
constants as unknown as {
__h: { findFilePathLinks: (t: string) => Found[]; resolveLinkedFilePath: (p: string, w?: string) => string };
}
).__h;
const folders = (text: string) =>
findFilePathLinks(text)
.filter((f) => f.folder)
.map((f) => f.path);
describe('folder links (findFilePathLinks)', () => {
it('links rooted, ~/ and slash-terminated relative folders', () => {
expect(folders('post mal /mnt/raid1/neon_getaway/styleframes')).toEqual(['/mnt/raid1/neon_getaway/styleframes']);
expect(folders('ls ~/repos/neon-menu/assets/')).toEqual(['~/repos/neon-menu/assets/']);
expect(folders('see builds/captures/ next')).toEqual(['builds/captures/']);
expect(folders('cd /home/joe/.codeman/app.')).toEqual(['/home/joe/.codeman/app']);
expect(folders('in (/mnt/raid1/foo), ok')).toEqual(['/mnt/raid1/foo']);
});
it('leaves files, prose, bare roots and URLs alone', () => {
expect(folders('/home/joe/a.png and /home/joe/a.rs')).toEqual([]);
expect(folders('Node.js and/or foo')).toEqual([]);
expect(folders('/home/ alone, ~/ alone')).toEqual([]);
expect(folders('https://x.io/home/joe/dir and http://host/a/')).toEqual([]);
expect(folders('/etc/nginx is not a link root')).toEqual([]);
});
it('ends a folder path at a colon (PATH lists), and stays linear on colon runs', () => {
expect(folders('export PATH=/home/u/bin:/usr/bin')).toEqual(['/home/u/bin']);
expect(folders('dirs /mnt/a/frames:/mnt/b/frames')).toEqual(['/mnt/a/frames', '/mnt/b/frames']);
const start = Date.now();
findFilePathLinks('a:'.repeat(16000) + '//');
expect(Date.now() - start).toBeLessThan(100);
});
it('keeps the file link where both could match, and orders by position', () => {
const found = findFilePathLinks('dir /mnt/a/frames then /mnt/a/frames/x.png');
expect(found.map((f) => [f.path, !!f.folder])).toEqual([
['/mnt/a/frames', true],
['/mnt/a/frames/x.png', false],
]);
});
});
describe('folder links are routed to the grid', () => {
it('terminal provider opens folder links in the grid', () => {
const src = read('terminal-ui.js');
expect(src).toMatch(/addLink\(found\.path, found\.index, !!found\.folder\)/);
expect(src).toMatch(/if \(folder\) \{\s*self\.openFolderGrid\(target, sessionId\);/);
});
it('response viewer marks folder links and opens them in the grid', () => {
const src = read('app.js');
expect(src).toMatch(/if \(folder\) link\.dataset\.folder = '1';/);
expect(src).toMatch(/pathLink\.dataset\.folder\) this\.openFolderGrid\(/);
});
it('ships the grid overlay and the preview buttons', () => {
const html = read('index.html');
for (const id of [
'folderGridOverlay',
'folderGridTitle',
'folderGridFolders',
'folderGridBody',
'folderGridFooter',
'folderGridUpBtn',
'filePreviewPrevBtn',
'filePreviewNextBtn',
'filePreviewFolderBtn',
]) {
expect(html, id).toContain(`id="${id}"`);
}
});
});
// ---------- panels-ui.js against a stub app ----------
type FakeEl = {
tagName: string;
children: FakeEl[];
textContent: string;
innerHTML: string;
className: string;
dataset: Record<string, string>;
hidden?: boolean;
disabled?: boolean;
title?: string;
src?: string;
onclick?: () => void;
onerror?: () => void;
classList: { add: (c: string) => void; remove: (c: string) => void; contains: (c: string) => boolean };
appendChild: (c: FakeEl) => void;
append: (...c: FakeEl[]) => void;
querySelector: (sel: string) => FakeEl | null;
querySelectorAll: (sel: string) => FakeEl[];
scrollIntoView: () => void;
};
function el(tagName = 'div'): FakeEl {
const classes = new Set<string>();
const node: FakeEl = {
tagName: tagName.toUpperCase(),
children: [],
textContent: '',
className: '',
dataset: {},
get innerHTML() {
return '';
},
set innerHTML(_v: string) {
node.children = [];
},
classList: {
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c) || node.className.split(' ').includes(c),
},
appendChild: (c) => void node.children.push(c),
append: (...c) => void node.children.push(...c),
querySelector: () => null,
querySelectorAll: () => [],
scrollIntoView: () => {},
} as FakeEl;
return node;
}
function loadApp(fetchImpl: (url: string, init?: { body?: string }) => unknown) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const keyHandlers: Array<(ev: unknown) => void> = [];
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
CodemanBase: { base: '', url: (p: string) => p },
resolveLinkedFilePath,
// Also from constants.js: no file in this suite renders as an HTML page.
rendersAsPage: () => false,
document: {
getElementById: () => null,
createElement: (t: string) => el(t),
addEventListener: (type: string, fn: (ev: unknown) => void) => {
if (type === 'keydown') keyHandlers.push(fn);
},
},
window: { addEventListener: vi.fn(), open: vi.fn() },
setTimeout,
clearTimeout,
confirm: () => true,
fetch: vi.fn(async (url: string, init?: { body?: string }) => fetchImpl(url, init)),
});
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const previewOverlay = el();
const gridOverlay = el();
const previewBody = el();
const gridBody = el();
const elements: Record<string, FakeEl> = {
filePreviewOverlay: previewOverlay,
filePreviewBody: previewBody,
filePreviewTitle: el(),
filePreviewFooter: el(),
filePreviewDetachBtn: el(),
filePreviewPrevBtn: Object.assign(el(), { hidden: true }),
filePreviewNextBtn: Object.assign(el(), { hidden: true }),
filePreviewFolderBtn: Object.assign(el(), { hidden: true }),
folderGridOverlay: gridOverlay,
folderGridTitle: el(),
folderGridFolders: el(),
folderGridBody: gridBody,
folderGridFooter: el(),
folderGridUpBtn: el(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app._resetFilePreviewEdit = () => {};
app.showToast = vi.fn();
app.formatFileSize = (n: number) => `${n} B`;
app.sessions = new Map([['s1', { workingDir: '/home/joe/wiki' }]]);
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-x' }));
return { app, elements, keyHandlers, fetch: context.fetch as ReturnType<typeof vi.fn> };
}
const LISTING = {
folderId: 'fld_1',
path: '/mnt/raid1/styleframes',
files: [
{ name: 'b.png', extension: 'png', attachmentType: 'image', size: 10, mtimeMs: 2 },
{ name: 'deck.pdf', extension: 'pdf', attachmentType: 'pdf', size: 20, mtimeMs: 1 },
{ name: 'clip.mp4', extension: 'mp4', attachmentType: 'video', size: 30, mtimeMs: 0 },
],
folders: ['old'],
truncated: false,
};
function ok(data: unknown) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
describe('openFolderGrid', () => {
it('grants the folder and renders a tile per file from the folder routes', async () => {
const { app, elements, fetch } = loadApp(() => ok(LISTING));
await app.openFolderGrid('~/styleframes', 's1');
expect(fetch).toHaveBeenCalledWith('/api/sessions/s1/folders', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ path: '~/styleframes' });
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true);
expect(elements.folderGridTitle.textContent).toBe('/mnt/raid1/styleframes');
const tiles = elements.folderGridBody.children;
expect(tiles).toHaveLength(3);
const thumb = (i: number) => tiles[i].children[0];
expect(thumb(0).children[0].src).toBe('/api/sessions/s1/folders/fld_1/files/b.png/raw');
expect(thumb(1).children[0].src).toBe('/api/sessions/s1/folders/fld_1/files/deck.pdf/thumbnail');
expect(thumb(2).children).toHaveLength(0);
expect(thumb(2).textContent).toBe('MP4');
expect(elements.folderGridFolders.children.map((c) => c.textContent)).toEqual(['old/']);
expect(elements.folderGridFooter.textContent).toBe('3 files • 1 folder');
});
it('falls back to the file preview when the path is a file', async () => {
const { app } = loadApp(() => ({ ok: false, status: 400, json: async () => ({ error: 'Not a folder' }) }));
app.openFilePreview = vi.fn();
await app.openFolderGrid('/mnt/raid1/notes', 's1');
expect(app.openFilePreview).toHaveBeenCalledWith('/mnt/raid1/notes', 's1');
});
it('toasts a missing or blocked folder', async () => {
const { app, elements } = loadApp(() => ({
ok: false,
status: 404,
json: async () => ({ error: 'Folder not found' }),
}));
await app.openFolderGrid('/mnt/raid1/gone', 's1');
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Folder not found'), 'error');
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
});
});
describe('folder preview paging', () => {
async function openGrid() {
const ctx = loadApp(() => ok(LISTING));
await ctx.app.openFolderGrid('/mnt/raid1/styleframes', 's1');
return ctx;
}
it('previews a tile from the folder routes, never registering an attachment', async () => {
const { app, elements } = await openGrid();
elements.folderGridBody.children[0].onclick?.();
await Promise.resolve();
expect(app._registerExternalPreview).not.toHaveBeenCalled();
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/folders/fld_1/files/b.png/raw');
expect(elements.filePreviewTitle.textContent).toBe('/mnt/raid1/styleframes/b.png');
expect(elements.filePreviewPrevBtn.hidden).toBe(false);
expect(elements.filePreviewNextBtn.hidden).toBe(false);
expect(elements.filePreviewFolderBtn.hidden).toBe(true);
});
it('pages with wrap-around and keeps arrow keys away from the terminal', async () => {
const { app, elements, keyHandlers } = await openGrid();
elements.folderGridBody.children[0].onclick?.();
elements.filePreviewOverlay.classList.add('visible');
app.stepFolderPreview(-1);
expect(app.folderGrid.index).toBe(2);
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/folders/fld_1/files/clip.mp4/raw');
const ev = {
key: 'ArrowRight',
target: { tagName: 'TEXTAREA', classList: { contains: (c: string) => c === 'xterm-helper-textarea' } },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
keyHandlers.forEach((h) => h(ev));
expect(app.folderGrid.index).toBe(0);
expect(ev.stopPropagation).toHaveBeenCalled();
const typing = {
...ev,
target: { tagName: 'INPUT', classList: { contains: () => false } },
stopPropagation: vi.fn(),
};
keyHandlers.forEach((h) => h(typing));
expect(app.folderGrid.index).toBe(0);
expect(typing.stopPropagation).not.toHaveBeenCalled();
});
it('Escape closes the preview first, then the grid', async () => {
const { app, elements, keyHandlers } = await openGrid();
elements.folderGridBody.children[1].onclick?.();
elements.filePreviewOverlay.classList.add('visible');
const esc = () => ({ key: 'Escape', target: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
keyHandlers.forEach((h) => h(esc()));
expect(elements.filePreviewOverlay.classList.contains('visible')).toBe(false);
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true);
expect(app.folderGrid.index).toBeNull();
keyHandlers.forEach((h) => h(esc()));
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
expect(app.folderGrid).toBeNull();
});
});
describe('stale loads', () => {
const TEXT_LISTING = {
...LISTING,
path: '/mnt/raid1/logs',
files: [
{ name: 'a.log', extension: 'log', attachmentType: 'text', size: 1, mtimeMs: 2 },
{ name: 'b.log', extension: 'log', attachmentType: 'text', size: 1, mtimeMs: 1 },
],
};
const text = (body: string) => ({ ok: true, status: 200, text: async () => body });
const flush = () => new Promise((r) => setTimeout(r, 0));
it("paging past a text file that is still loading never shows its text under the next file's title", async () => {
const pending: Record<string, (v: unknown) => void> = {};
const { app, elements } = loadApp((url) =>
url.endsWith('/folders') ? ok(TEXT_LISTING) : new Promise((r) => (pending[url] = r))
);
await app.openFolderGrid('/mnt/raid1/logs', 's1');
app._renderFilePreviewText = vi.fn();
elements.folderGridBody.children[0].onclick?.();
elements.filePreviewOverlay.classList.add('visible');
app.stepFolderPreview(1);
await flush();
pending['/api/sessions/s1/folders/fld_1/files/b.log/raw'](text('B'));
await flush();
pending['/api/sessions/s1/folders/fld_1/files/a.log/raw'](text('A'));
await flush();
expect(elements.filePreviewTitle.textContent).toBe('/mnt/raid1/logs/b.log');
expect(app.filePreviewContent).toBe('B');
expect(app.filePreviewText.filePath).toBe('/mnt/raid1/logs/b.log');
expect(app._renderFilePreviewText).toHaveBeenCalledTimes(1);
});
it('two quick folder opens render only the newer one, and a closed grid stays closed', async () => {
const pending: Record<string, (v: unknown) => void> = {};
const { app, elements } = loadApp((_url, init) => {
const path = JSON.parse(init?.body || '{}').path;
return new Promise((r) => (pending[path] = r));
});
const first = app.openFolderGrid('/mnt/raid1/styleframes', 's1');
const second = app.openFolderGrid('/mnt/raid1/logs', 's1');
pending['/mnt/raid1/logs'](ok(TEXT_LISTING));
await second;
pending['/mnt/raid1/styleframes'](ok(LISTING));
await first;
expect(app.folderGrid.path).toBe('/mnt/raid1/logs');
expect(elements.folderGridTitle.textContent).toBe('/mnt/raid1/logs');
const late = app.openFolderGrid('/mnt/raid1/styleframes', 's1');
app.closeFolderGrid();
pending['/mnt/raid1/styleframes'](ok(LISTING));
await late;
expect(app.folderGrid).toBeNull();
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
});
});
describe('"Show folder" in the preview', () => {
it('knows the folder of absolute, relative and ~/ paths, not of a bare attachment card', () => {
const { app } = loadApp(() => ok(LISTING));
expect(app._previewFolderPath('/mnt/raid1/x/a.png', 's1', null)).toEqual({ dir: '/mnt/raid1/x', sessionId: 's1' });
expect(app._previewFolderPath('builds/a.png', 's1', null)).toEqual({
dir: '/home/joe/wiki/builds',
sessionId: 's1',
});
expect(app._previewFolderPath('~/r/a.png', 's1', null)).toEqual({ dir: '~/r', sessionId: 's1' });
expect(app._previewFolderPath('a.png', 's1', 'att-1')).toBeNull();
});
it('shows the button for a previewed file and opens its folder', async () => {
const { app, elements, fetch } = loadApp(() => ok(LISTING));
await app.openFilePreview('/mnt/raid1/styleframes/b.png', 's1');
expect(elements.filePreviewFolderBtn.hidden).toBe(false);
expect(elements.filePreviewPrevBtn.hidden).toBe(true);
app.openFolderOfPreview();
await vi.waitFor(() => expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true));
expect(JSON.parse(fetch.mock.calls.at(-1)[1].body)).toEqual({ path: '/mnt/raid1/styleframes' });
});
});

Some files were not shown because too many files have changed in this diff Show More