Compare commits

..
Author SHA1 Message Date
Codeman maintainer a8a8bb71a3 docs(media): keep video off GitHub, document where media lives
Only GIFs that a public page embeds stay in docs/images/: the five README
GIFs plus subagent-demo-20260724.gif, which the published "mission control"
article hotlinks from master. Seven GIFs nothing embeds leave the tree
(their masters are kept in the maintainer's local media library).

.gitignore now blocks *.mp4/*.webm/*.mov/*.m4v/*.mkv/*.avi/*.gif outside
docs/images/*.gif (and the promo branch's Remotion inputs), so a video can
only be committed with an explicit `git add -f`.

docs/media-assets.md records the rule: what is tracked and where it is
embedded, website media (pr/website/assets, deployed separately), release
clips on the design-assets branch, and the local library layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:10:06 +02:00
Codeman maintainer 1a692f588e docs(readme.zh-CN): translate the remaining English-only sections
The installer's three up-front questions, the :443 fallback, the
optional tailnet rename, command-line flags and install.sh status; the
installer's closing QR note in the Tailscale tip; Diagnostics in
Settings, the bottom-bar git status and creating a case in a custom
folder; docker load in the Docker export bullet; and the installer
note on the loopback default. The Chinese README now carries the same
content as the English one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:46:05 +02:00
Codeman maintainer b68b098d1c docs(readme.zh-CN): catch the Chinese README up to 1.41.0 and 1.41.1
Mirrors the English README update: tile grid ranking, saved layouts,
tile animations, wheel scrolling in tiles, Detach Tiles and gesture
moves; rail search, tab groups and Focus; HTML pages, the folder grid
and clickable ~/ and relative paths; .codeman-uploads and the phone
Attach button; OSC 52, the prompt-cache readout, MCP server sync with
Copilot CLI, Settings Apply and notification display times; remote SSH
scrollback; the sandboxed HTML page view in Security; Ctrl+W and the
tile shortcuts in the shortcut table.

Adds what the Chinese README never had: the `codeman agent` and
`codeman mcp` sections, the agent skill for every CLI (install table,
--mode, the Codeman Agent Skill setting, the skill GIF), and the
refreshed dashboard tour and tab-states images.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:46:05 +02:00
Codeman maintainer 03e6a159b1 docs(readme): catch the README up to 1.41.0 and 1.41.1
Tile grid (session ranking, saved layouts, tile animations, wheel
scrolling in tiles, Detach Tiles, gesture moves), rail search, tab
groups and Focus, HTML pages and the folder grid, clickable ~/ and
relative paths, .codeman-uploads and the phone Attach button with
videos, OSC 52, the prompt-cache readout, MCP server sync with Copilot
CLI, Settings Apply, notification display times and remote SSH
scrollback.

Also corrects three stale claims: HTML now renders in the sandboxed
page view, Ctrl+W has not closed a session since 1.40.0, and the tile
shortcuts were missing from the table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:46:05 +02:00
Codeman maintainer 29f76e7ec3 docs: split CLAUDE.md into root, src/web/CLAUDE.md and docs/releasing.md
The root CLAUDE.md had grown to 202 KB, all of it loaded into every
session. This lands the split drafted on 2026-10-10 (it sat uncommitted
in the shared checkout through two releases), redone on top of today's
master so nothing that landed since is lost:

- src/web/CLAUDE.md: the frontend invariants (every src/web/public/ UI
  subsystem, the module notes, the XLSX preview limits, the z-index
  stack). Claude Code loads it when a file under src/web/ is read; the
  root file tells sessions to Read it before editing src/web/public/.
  Its link targets are relative to the file so they resolve on GitHub.
- docs/releasing.md: the COM procedure (now nine steps, with the MCP
  registry publish), behind a pointer in the root COM section. The
  Version line stays in the root file.
- Root CLAUDE.md (130 KB): everything else, plus the split's trims: the
  Core Files table, the reference-only rows of the Additional Commands
  table, Data Flow and the respawn presets, whose rules already live in
  the root's other paragraphs (the "Read before touching a subsystem"
  line keeps the table's doc pointers).

Every other paragraph of master's CLAUDE.md lands in exactly one of the
three files, checked line by line; the paragraphs master changed since
the draft (tile grid, OSC 52, prompt uploads, file-path links and the
HTML view, markdown preview, z-index, MCP server, agent CLI and others)
are in their current form. Both new files join CLAUDE.md in
.prettierignore. Four code comments and docs/architecture-invariants.md
now point at src/web/CLAUDE.md for the moved sections.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:43:45 +02:00
Codeman maintainer bc2d42c2c8 docs(release): publish each release to the MCP registry (COM step 8)
`codeman mcp` is listed in the official MCP registry as
io.github.Ark0N/codeman (first published for 1.41.1). The registry does
not follow npm, so without a step in the COM procedure the listing stays
on the version it was last published with. The step also records the
two traps hit on the first publish: the Snap Store mcp-publisher (1.1.0)
rejects the current server.json schema with a false "deprecated schema
detected", and older publishers save live login tokens into the current
directory as .mcpregistry_* files.

The announcement step becomes step 9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:40:33 +02:00
Codeman maintainer af8d0ef69c docs(readme): use the 1600px hero GIF at 800px display width
The 800px GIF rendered the header stats strip (CPU, MEM, 5H/7D) at ~5px
text height. The 1600px source shown at width=800 stays sharp on high-DPI
screens. New filename so GitHub's image cache serves the new file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:09:20 +02:00
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
Codeman maintainer ef071ed485 feat(agent-skill): give the codeman skill to every agent CLI, not just Claude
The agent skill only reached Claude Code: injection wrote
.claude/skills/codeman, which no other CLI reads, and the per-session
preamble was seeded for claude alone. A Codex, Gemini, OpenCode,
Antigravity, Grok, DeepSeek or OMP session inside Codeman never learned it
could start and drive workers.

- Registry field capabilities.agentSkillDir (a closed enum, since
  clis.json can set it and it decides where the server writes) names the
  skills directory each CLI scans. Claude keeps .claude/skills; those seven
  CLIs get the same skill in <case>/.agents/skills/codeman (each measured to
  scan it; Claude Code does not, so it never sees two copies). pi gets none:
  a project skill makes pi ask "Trust project folder?" at startup.
- The preamble is seeded for every local session whose CLI takes the
  skill; nothing in it is claude-specific.
- The skill text is CLI-neutral and names the two CLI-side failures to
  report instead of retrying: a sandbox that blocks loopback (Codex's
  default), and commands that do not carry this session's environment
  (Codex 0.162 runs them in a shared app-server daemon).
- POST /api/sessions injects after the launch pre-flight, so a CLI that
  cannot start leaves nothing in the workspace.
- codeman skill install gains --mode; uninstall --case removes the copy
  from every directory the registry can write.
- The settings toggle moves to Agents & CLIs > All agent CLIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:33:47 +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
Ark0NandClaude Opus 5.5 aa3730aace Merge pull request #589: docs(cron): document job API and launch statuses
Fixes #587. Supersedes #597, closed as a duplicate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:41:08 +02:00
Codeman maintainer 6234aae919 docs(readme): codeman skill GIF with the CRT tile grid and a mixed fleet
The agent skill section now shows a real run end to end: one short
prompt typed into Claude Code, the tile grid powering on with the CRT
entrance, then a DeepSeek Harness worker on a local qwen model and a
Claude Code worker powering on as new tiles, with lineage lines back to
the lead. 1280 wide, linking a 3600x2025 still.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:29:01 +02:00
w3lld1 34439d18e5 docs(cron): address linked guide and run history review
Signed-off-by: w3lld1 <42353747+w3lld1@users.noreply.github.com>
2026-10-10 09:27:54 +02: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
w3lld1 45a205b4e5 docs(cron): document job API and launch statuses
I corrected the toolbar location and documented the existing cron request and response fields, with checks for route, schema, and status coverage.
2026-10-10 06:49:58 +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
199 changed files with 18791 additions and 941 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': minor
---
Tiles move by hand, and can leave the window. With gesture control on, pinch a tile and carry it onto another tile (they swap) or an empty cell (it moves there), or carry a tab onto the grid to tile it. A new **Detach Tiles** setting (App Settings → Appearance, per device, off by default) lets you drag a tile's header out of the browser to open it in its own window, drop it on another Codeman window's tiles to move it there, and drag a popped-out window's title back onto any grid. The tile's ⋯ menu gets "Open in a new window" too.
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman", "name": "codeman",
"source": "./plugins/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.", "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": { "author": {
"name": "Ark0N", "name": "Ark0N",
"url": "https://github.com/Ark0N" "url": "https://github.com/Ark0N"
+17
View File
@@ -84,6 +84,23 @@ tmp/
# added there). No trailing slash so it still matches the symlink, not just dirs. # added there). No trailing slash so it still matches the symlink, not just dirs.
/public /public
# Video never goes to GitHub (docs/media-assets.md). The only tracked moving
# images are the GIFs a public page embeds, in docs/images/; masters, promo cuts,
# drafts and captures live in the local media library pr/media/ (ignored above).
# The promo branch tracks its Remotion render inputs, so those stay addable.
# A deliberate exception elsewhere takes `git add -f`.
*.mp4
*.webm
*.mov
*.m4v
*.mkv
*.avi
*.gif
!/docs/images/*.gif
!/scripts/remotion/public/**/*.mp4
!/scripts/remotion/public/**/*.webm
!/scripts/remotion/public/**/*.gif
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB) # Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git. # fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
# (The gesture bundle itself, gesture-codeman.js, IS tracked — built from # (The gesture bundle itself, gesture-codeman.js, IS tracked — built from
+2
View File
@@ -28,3 +28,5 @@ scripts/remotion/
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs. # Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
CLAUDE.md CLAUDE.md
src/web/CLAUDE.md
docs/releasing.md
+51
View File
@@ -1,5 +1,56 @@
# aicodeman # 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 ## 1.41.0
### Minor Changes ### Minor Changes
+18 -164
View File
File diff suppressed because one or more lines are too long
+89 -19
View File
@@ -24,7 +24,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800"> <img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
</p> </p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. **Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
@@ -46,6 +46,7 @@ The installer asks before every system change, and re-running the same line upda
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
- **Agents can drive it too** - the [codeman skill](#the-agent-skill-start-here) (for every agent CLI), [`codeman agent`](#codeman-agent--session-to-session-verbs-in-every-cli-mode) and an [MCP server](#mcp-server-codeman-mcp-drive-codeman-from-any-mcp-client) let one agent start workers in any CLI, prompt them, wait for them and collect their answers
- **Nothing gets lost** - tmux persistence across restarts and network drops, exactly-once input delivery, full-scrollback replay - **Nothing gets lost** - tmux persistence across restarts and network drops, exactly-once input delivery, full-scrollback replay
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine - **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
@@ -212,6 +213,8 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack) - **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack)
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded - **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
- **Attach button**: the picture icon beside the mic opens the system photo and file sheet without raising the keyboard. Photos and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into the session's folder and their path lands on your prompt, unsent
- **Paste in a shell**: a multi-line paste goes in as one block and waits for your Enter, instead of running line by line as it arrives
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API) - **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling; on a folding phone (iPhone Duo) dialogs stay clear of the hinge, and opening or closing the device is never mistaken for the keyboard - **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling; on a folding phone (iPhone Duo) dialogs stay clear of the hinge, and opening or closing the device is never mistaken for the keyboard
@@ -264,18 +267,18 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 3. Read the dashboard ### 3. Read the dashboard
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet. - **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet. The rail also has a **Search sessions** box, named **tab groups** (a group's menu starts a **New session** straight into it), and a **Focus** section of shortcuts to the tabs you keep coming back to.
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline. - **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar). - **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
### 4. Talk to the agent ### 4. Talk to the agent
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt). - **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
- **Paste or drag-and-drop images** directly into the session. - **Paste, drop or attach images and videos** directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop). - **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view. - **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view, including `~/` and relative paths and paths broken across lines. An `.html` report opens as a real page, Markdown opens rendered, and a folder opens as a grid of its files.
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work. - **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it. - **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it. A program that copies with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard too.
### 5. Make it autonomous ### 5. Make it autonomous
@@ -294,7 +297,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 7. Operate & maintain ### 7. Operate & maintain
- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance animations, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language. - **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance and tile animations (their own **Animations** section), notifications (with adjustable toast and browser-notification display times), display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language. **Apply** saves without closing Settings.
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation). - **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**. - **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
- **Deploy your own changes** — see [Development](#development). - **Deploy your own changes** — see [Development](#development).
@@ -387,7 +390,14 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800"> <img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
</p> </p>
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session; the grid is remembered for next time. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md). Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
- **Opens what matters**: with no grid arranged yet, it fills the tiles with the sessions that are working first, then the ones waiting on you, then the most recently used, and always includes the one you are on.
- **Keeps your layout**: once you arrange a grid (which session sits where, empty cells, tile count, divider sizes, the focused and zoomed tile), the Tiles button brings it back exactly, and so does a page reload while it was open.
- **Tile animations** _(opt-in)_: **App Settings → Animations → Tile Animations** makes the grid open with a show: **CRT** (each tile switches on as a hot line, as in the GIF at the top), **Fly from tab**, **Deal**, **Beam down**, **Cascade**, **Pop** or **Soft**. Nothing moves under reduced motion.
- **Scrolls like the main terminal**: the mouse wheel scrolls a fullscreen Claude conversation inside its tile, a Codex tile pages its transcript, and `Shift+wheel` scrolls the tile's local history.
- **Detach Tiles** _(beta, opt-in)_: with **App Settings → Appearance → Detach Tiles** on, drag a tile's header out of the browser and it pops out into its own window; drop it on another Codeman window's tiles and it moves there.
- **Hands-free**: with the [gesture overlay](#more-features) 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.
### Persistent Sessions ### Persistent Sessions
@@ -464,10 +474,15 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input - **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam - **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons - **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
- **HTML reports open as pages**: click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer shows it as a real page, with the scripts, styles, images and data files from its folder; the **Page** pill switches to the source and its Edit button. The page runs sandboxed with an opaque origin, so it cannot read Codeman's cookies, call Codeman's API or read files outside its own folder. Network access stays open, so a dashboard that loads Chart.js or fonts from a CDN works as it would from disk. Broad folders (`/`, your home, `/tmp`) are refused, so give a report a folder of its own. A `.md` path clicked in the terminal opens rendered too
- **Clickable paths and the folder grid**: paths written as `~/…`, relative to the session folder, or broken across terminal lines are clickable in the terminal and the Response Viewer, and so are folders: a folder opens a grid of its files (renders, screenshots, reports) that you page through with the arrow keys
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones - **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in entrance animations for tabs, agent windows, the terminal pane and connection lines - **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in animations for tabs, agent windows, the terminal pane, connection lines and [tile-grid tiles](#tile-grid), all in App Settings → Animations (with a lab to try them)
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous - **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
--- ---
@@ -499,6 +514,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count. - **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded. - **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404. - **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
- **Full scrollback**: loading a remote session's history reads it from the remote tmux in one bounded ssh round trip, so a session keeps its whole scrollback after a reattach or a tab switch, with no rows lost where the history meets the live screen.
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md). Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
@@ -669,7 +685,7 @@ These run for **every** request — before auth, even on the default no-password
### Input, files & headers ### Input, files & headers
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users - **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes - **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes. The one place HTML renders is the File Viewer's page view: a separate `/html-view/<capability>/` address scoped to the file's own folder, served under `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page has an opaque origin and can never read Codeman's cookies or drive its API; hidden folders and broad roots (`/`, home, `/tmp`) are refused, and the capabilities are revoked on logout
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1` - **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
### Supply chain & isolation ### Supply chain & isolation
@@ -703,7 +719,7 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
| Shortcut | Action | | Shortcut | Action |
| ------------------------------- | ------------------------------------------------------------- | | ------------------------------- | ------------------------------------------------------------- |
| `Ctrl/Cmd+W` | Kill active session | | `Ctrl+W` | Not a Codeman shortcut: it reaches the terminal (delete the previous word). **Close Session** has no default key and can be bound in App Settings → Shortcuts |
| `Ctrl/Cmd/Option+K` | Find open session or start a new one | | `Ctrl/Cmd/Option+K` | Find open session or start a new one |
| `Ctrl/Cmd+Tab` | Next session | | `Ctrl/Cmd+Tab` | Next session |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session | | `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
@@ -716,6 +732,10 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
| `Ctrl/Cmd+L` | Clear terminal | | `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size | | `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input | | `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl+Shift+G` | Open or close the tile grid |
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below |
| `Ctrl+Shift+Arrows` | Move the focused tile one place (into an empty cell, or swap) |
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid |
| `Ctrl/Cmd +` / `-` | Font size | | `Ctrl/Cmd +` / `-` | Font size |
| `Ctrl/Cmd+?` | Keyboard help | | `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) | | `Shift+Enter` | Insert newline (sent to terminal) |
@@ -733,23 +753,23 @@ For AI agents and automation that control Codeman without a browser: an agent th
### The agent skill (start here) ### The agent skill (start here)
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself. Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
<p align="center"> <p align="center">
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a> <a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
</p> </p>
#### Step 1: install it #### Step 1: install it
| How | Command | Scope | | How | Command | Scope |
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent | | Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) | | Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo | | Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
| Bundled CLI | `codeman skill install --case <name>` | One case only | | Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) | | Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a `skills/codeman` you wrote yourself. `codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
#### Step 2: ask for things #### Step 2: ask for things
@@ -802,7 +822,7 @@ Every recipe in there was verified against a live server, and the comments recor
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing. - **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing.
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target. - **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` dir). Remove them per case with `codeman skill uninstall --case <name>`. ⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
--- ---
@@ -946,7 +966,7 @@ codeman tui 3 # attach to session 3 of that list
### `codeman agent` — session-to-session verbs in every CLI mode ### `codeman agent` — session-to-session verbs in every CLI mode
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
```bash ```bash
codeman agent ls # sessions; * marks this one codeman agent ls # sessions; * marks this one
@@ -962,6 +982,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. 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) ### 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. 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.
+125 -27
View File
@@ -24,7 +24,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800"> <img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
</p> </p>
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。 > 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
@@ -48,11 +48,12 @@ codeman web
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知 - **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行 - **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录 - **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
- **智能体也能驱动它**:[codeman 技能](#智能体技能从这里开始)(适用于所有智能体 CLI)、[`codeman agent`](#codeman-agent每种-cli-模式都能用的会话间命令) 和一个 [MCP 服务器](#mcp-服务器codeman-mcp从任意-mcp-客户端驱动-codeman),让一个智能体在任意 CLI 里拉起工作会话、给它们发提示、等它们完成并收回答案
- **什么都不会丢**:tmux 让会话挺过重启和断网,输入精确一次送达,完整的回滚缓冲区回放 - **什么都不会丢**:tmux 让会话挺过重启和断网,输入精确一次送达,完整的回滚缓冲区回放
- **自托管、私有**:默认仅环回、MIT 许可、无遥测,完全运行在你自己的机器上 - **自托管、私有**:默认仅环回、MIT 许可、无遥测,完全运行在你自己的机器上
<p align="center"> <p align="center">
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900"> <img src="docs/images/codeman-tour-20261010.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
</p> </p>
--- ---
@@ -63,12 +64,13 @@ codeman web
curl -fsSL https://getcodeman.com/install | bash curl -fsSL https://getcodeman.com/install | bash
``` ```
该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知: 该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。它会先看看机器上已有什么,最多问三个问题,然后无人值守地完成所有工作,最后给出访问 URL 和一个供手机扫描的二维码。几点须知:
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。 - **三个问题,全部在开头问完。** 仪表盘怎么访问、(可选)这台机器在你的 tailnet 里叫什么,以及是否把 Codeman 作为后台服务运行(systemd/launchd,开机自启;直接回车即为「是」)。所有需要你出面的事,包括对所有缺失软件包的一次性同意、一次 sudo 密码和 Tailscale 登录,都在构建之前完成,所以编译期间你可以走开。
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。手动运行的 `codeman web` 仍默认仅环回。 - **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。如果你节点上的 `:443` 已被别的应用占用,Codeman 会改用 `https://<机器名>.<tailnet>.ts.net/codeman` 或第二个端口,而不是把它替换掉。手动运行的 `codeman web` 仍默认仅环回。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。 - **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。 - **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后: 你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
@@ -213,6 +215,8 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触;在 Codex 会话上还会显示 `⇧←` / `⇧→`(Shift+Left / Shift+Right:编辑上一条排队的消息 / 在提示栈里回退) - **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触;在 Codex 会话上还会显示 `⇧←` / `⇧→`(Shift+Left / Shift+Right:编辑上一条排队的消息 / 在提示栈里回退)
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上 - **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
- **附件按钮**:麦克风旁边的图片图标会直接打开系统的照片与文件面板,不会弹出键盘。照片和相册里的视频(mp4、mov、webm,最大 1 GB)会上传到会话所在的文件夹,文件路径落在你的提示里,但不会自动发送
- **在 shell 里粘贴**:多行粘贴会作为一整块插入并等你按 Enter,而不是一边到达一边逐行执行
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API) - **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动;折叠屏手机(iPhone Duo)上对话框会避开铰链,开合设备也绝不会被误判成键盘弹出 - **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动;折叠屏手机(iPhone Duo)上对话框会避开铰链,开合设备也绝不会被误判成键盘弹出
@@ -221,7 +225,7 @@ codeman web --https
# 在手机上打开:https://<你的IP>:3000 # 在手机上打开:https://<你的IP>:3000
``` ```
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。 > `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。安装器最后会给出这个 URL 和一个可扫描的二维码,之后随时可以用 `bash ~/.codeman/app/install.sh status` 再次打印。
### 安全的二维码认证 ### 安全的二维码认证
@@ -265,18 +269,18 @@ codeman service install # systemd/launchd 服务:重启后自动回
### 3. 读懂仪表盘 ### 3. 读懂仪表盘
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。 - **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。导轨上还有一个 **Search sessions** 搜索框、命名的**标签分组**(分组菜单里的 **New session** 会直接在该组里新建会话),以及一个 **Focus** 区,放着你常回去的那些标签的快捷方式。
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。 - **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。 - **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
### 4. 与智能体对话 ### 4. 与智能体对话
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。 - **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
- **粘贴或拖放图片**,直接进入会话。 - **粘贴、拖放或附加图片和视频**,直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。 - **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行。 - **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行,包括 `~/` 路径、相对路径和被折成多行的路径。`.html` 报告会以真实网页打开,Markdown 会渲染后打开,文件夹则以其文件的网格打开。
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。 - **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。 - **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。用 OSC 52 复制的程序(Claude Code 的 `/copy`、tmux 的 yank、vim)也会把文本放进你浏览器的剪贴板。
### 5. 让它自主运行 ### 5. 让它自主运行
@@ -295,7 +299,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
### 7. 运维与维护 ### 7. 运维与维护
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场动画、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。 - **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场与平铺动画(有自己的 **Animations** 分区)、通知(toast 和浏览器通知的显示时长可调)、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。**Apply** 按钮保存设置但不关闭设置窗口。
- **让它在后台运行** —— `codeman web -d` 脱离你的 shell(`--status`、`--stop`);`codeman service install` 把它装成 systemd 用户单元 / macOS LaunchAgent,重启后自动回来。两者都会先确认服务器真正应答再报告成功,也都拒绝在同一个数据目录上启动第二个服务器。见[让它在后台一直运行](#快速开始--安装)。 - **让它在后台运行** —— `codeman web -d` 脱离你的 shell(`--status`、`--stop`);`codeman service install` 把它装成 systemd 用户单元 / macOS LaunchAgent,重启后自动回来。两者都会先确认服务器真正应答再报告成功,也都拒绝在同一个数据目录上启动第二个服务器。见[让它在后台一直运行](#快速开始--安装)。
- **自更新** —— git-clone 安装可在 **App Settings → System → Updates** 中原地更新。 - **自更新** —— git-clone 安装可在 **App Settings → System → Updates** 中原地更新。
- **部署你自己的改动** —— 见[开发](#开发)。 - **部署你自己的改动** —— 见[开发](#开发)。
@@ -388,7 +392,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
<img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800"> <img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800">
</p> </p>
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话,网格会被记住,下次直接恢复。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。 在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
- **先开重要的**:还没有排好网格时,它会先放正在工作的会话,然后是等你处理的,再是最近用过的,并且总会包含你当前所在的会话。
- **记住你的布局**:一旦你排好网格(哪个会话在哪、空着的格子、窗格数、分隔条大小、聚焦和放大的窗格),平铺按钮会原样恢复它,网格打开时刷新页面也一样。
- **平铺动画**(可选启用):**App Settings → Animations → Tile Animations** 让网格华丽登场:**CRT**(每个窗格像老显像管一样以一道亮线开机,就是顶部 GIF 里那样)、**Fly from tab**、**Deal**、**Beam down**、**Cascade**、**Pop** 或 **Soft**。开启「减少动态效果」时一切静止。
- **滚动和主终端一样**:鼠标滚轮会在窗格里滚动全屏 Claude 的对话,Codex 窗格会翻页查看它的记录,`Shift+滚轮` 滚动窗格的本地历史。
- **分离窗格(Detach Tiles)**(测试版,可选启用):打开 **App Settings → Appearance → Detach Tiles** 后,把窗格标题栏拖出浏览器,它就弹出成独立窗口;拖到另一个 Codeman 窗口的窗格上,它就移到那边。
- **徒手操作**:开启[手势叠加层](#更多特性)后,捏住一个窗格拖到另一个窗格上即可交换,拖到空格子上即可移动,把一个标签拖进网格即可加入。
### 持久化会话 ### 持久化会话
@@ -423,7 +434,7 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
### 标签提醒(Tab Alerts) ### 标签提醒(Tab Alerts)
<p align="center"> <p align="center">
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="会话标签:一个普通的活动标签,旁边是黄色的等待输入标签和红色的需要决定标签,都带着呼吸式光晕" width="900"> <a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="标签状态(带标注):一个绿色描边在转的工作中标签,一个被智能体的问题挡住的红色标签(问题显示在它下方),以及一个回合已完成的黄色标签,两个提醒标签都在呼吸闪烁" width="900"></a>
</p> </p>
每个标签一眼就能看出状态。运行中的会话保持绿色状态点。会话停下来等待输入时,标签变**黄**:稳定的描边、着色的背景、黄色的点,上面叠一层缓慢的呼吸光晕。当权限提示或提问**挡住**了智能体,标签变**红**,脉动更快。底色永远不会闪灭,所以哪怕只瞥一眼(或截一张图)也能读到真实状态;标签被选中时描边依然可见,页面刷新后会从服务端重新装载待处理的提醒,因此一个被挡住的会话绝不可能藏在一个看起来正常的标签后面。 每个标签一眼就能看出状态。运行中的会话保持绿色状态点。会话停下来等待输入时,标签变**黄**:稳定的描边、着色的背景、黄色的点,上面叠一层缓慢的呼吸光晕。当权限提示或提问**挡住**了智能体,标签变**红**,脉动更快。底色永远不会闪灭,所以哪怕只瞥一眼(或截一张图)也能读到真实状态;标签被选中时描边依然可见,页面刷新后会从服务端重新装载待处理的提醒,因此一个被挡住的会话绝不可能藏在一个看起来正常的标签后面。
@@ -449,8 +460,11 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
## 更多特性 ## 更多特性
- **后台守护进程与服务安装** —— `codeman web -d` 以脱离终端的方式运行服务器,带 pid 文件、`~/.codeman/web.log` 和经过校验的启动(它会轮询到服务器应答为止,所以端口冲突绝不会被当成成功);`codeman service install` 写入一个 systemd 用户单元(Linux)或 LaunchAgent(macOS),并把你 shell 的 PATH 一并写进去,这样 nvm 或 Homebrew 装的 `node`、`tmux` 和 `claude` 才真的找得到。机密永远不会写进单元文件 - **后台守护进程与服务安装** —— `codeman web -d` 以脱离终端的方式运行服务器,带 pid 文件、`~/.codeman/web.log` 和经过校验的启动(它会轮询到服务器应答为止,所以端口冲突绝不会被当成成功);`codeman service install` 写入一个 systemd 用户单元(Linux)或 LaunchAgent(macOS),并把你 shell 的 PATH 一并写进去,这样 nvm 或 Homebrew 装的 `node`、`tmux` 和 `claude` 才真的找得到。机密永远不会写进单元文件
- **设置里的诊断**:**App Settings → System → Diagnostics → Run checks** 会在服务器上运行 `codeman doctor`,列出 Node、tmux、每个智能体 CLI 以及可选的 Office 工具,附带版本、路径和安装提示。除了 `PATH`,它还会查找每个 CLI 惯常的安装目录(`~/.local/bin`、`~/.npm-global/bin` 之类),所以即使服务只拿到一个最小的 `PATH`,大多数安装也能被找到。多用户模式下仅管理员可用
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → System → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新) - **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → System → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据 - **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md) - **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md) - **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md) - **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
@@ -462,10 +476,15 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Terminal & Input 启用 - **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Terminal & Input 启用
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝 - **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
- **文件查看器按钮** _(可选)_ —— 页头新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Header & Panels → Header buttons 中启用 - **文件查看器按钮** _(可选)_ —— 页头新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Header & Panels → Header buttons 中启用
- **HTML 报告以网页打开**:点击智能体打印出的 `.html` 路径(测试报告、覆盖率页面、仪表盘),文件查看器会把它显示成一个真实网页,并带上同一文件夹里的脚本、样式、图片和数据文件;**Page** 胶囊按钮可切到源码及其 Edit 按钮。页面在沙箱里以不透明源运行,所以它读不到 Codeman 的 cookie、调不了 Codeman 的 API,也读不了自己文件夹以外的文件。网络访问保持开放,因此从 CDN 加载 Chart.js 或字体的仪表盘和直接从磁盘打开时一样能用。范围过大的文件夹(`/`、你的主目录、`/tmp`)会被拒绝,所以请给报告一个单独的文件夹。在终端里点击的 `.md` 路径也会渲染后打开
- **可点击的路径与文件夹网格**:写成 `~/…`、相对于会话文件夹或被折成多行的路径,在终端和回复查看器(Response Viewer)里都能点击,文件夹也可以:点击文件夹会打开它的文件网格(渲染图、截图、报告),用方向键翻看
- **OSC 52 剪贴板**:会话里的程序用 OSC 52 设置剪贴板时(Claude Code 的 `/copy`、tmux 的 yank、vim),文本会进入你浏览器的剪贴板,主终端和每个平铺窗格都一样。读取剪贴板的请求一律拒绝,回放缓冲区也绝不会触发复制
- **MCP 服务器同步**(可选启用):把每个已安装 CLI 的用户级 MCP 服务器(Claude、Codex、Gemini、OpenCode、Antigravity,以及 GitHub Copilot CLI)复制进其他 CLI 自己的配置文件。只增不改,你关掉的服务器绝不会被复制,**Preview** 会先列出改动,原文件保留为 `.codeman-bak`。位于 App Settings → Agents & CLIs;多用户模式下仅管理员可用
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入,Ctrl、Alt 修饰的导航键也会原样透传给 CLI - **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入,Ctrl、Alt 修饰的导航键也会原样透传给 CLI
- **页头里的套餐用量** —— 页头实时显示 Claude 订阅用量(5 小时窗口与每周窗口),数据来自 Codeman 在拉起 `claude` 时临时交给它的 statusline 导出器,绝不会写进你的设置文件;Codex 的限额则来自它自己的 app-server。按设备生效:桌面默认开,手机默认关 - **页头里的套餐用量** —— 页头实时显示 Claude 订阅用量(5 小时窗口与每周窗口),数据来自 Codeman 在拉起 `claude` 时临时交给它的 statusline 导出器,绝不会写进你的设置文件;Codex 的限额则来自它自己的 app-server。按设备生效:桌面默认开,手机默认关
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序 - **提示缓存状态**:每个 Claude 会话都会显示它的提示缓存是热的(`cache:until HH:MM`)还是冷的,显示在页脚(前提是你没有自己的 statusline)和 Session Options → Respawn 中,并给出重新缓存的代价,压缩上下文后还会附一条说明。只做显示,重启后依然保留
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的入场动画,覆盖标签、智能体窗口、终端面板和连接线 - **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序。导轨上的 **Search sessions** 会把列表筛到名字匹配的标签,跨所有分组;带提醒的标签即使名字不匹配也始终可见。命名的**标签分组**可以折叠、重排和拖入拖出;分组菜单里有 **New session**,智能体拉起的工作会话会落在它的父会话后面(**Spawned tabs follow their parent**,默认关闭,开启后它会跟着父会话移动)。**Add to Focus** 会在导轨顶部的一个区里为任意标签放一个快捷方式,**CLI Logos on Tabs** 可以按设备关闭
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的动画,覆盖标签、智能体窗口、终端面板、连接线和[平铺网格的窗格](#平铺网格),全部位于 App Settings → Animations(附带一个试玩用的动画实验室)
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊 - **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
--- ---
@@ -480,7 +499,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。 - **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。 - **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
- **挂到你已经在跑的容器上** —— 在 Docker 面板勾选 **Attach to an existing container**,就能把 case 链接到一个现成容器,而不是新建一个。Codeman 只 `exec` 进去,绝不启动、停止、重启或删除它;一个被接管的容器可以在不同目录下支撑多个 case,**复制一个已有 case** 会用同一容器上的兄弟 case 预填表单。多用户模式下仅管理员可用,因为容器的挂载属于启动它的人。 - **挂到你已经在跑的容器上** —— 在 Docker 面板勾选 **Attach to an existing container**,就能把 case 链接到一个现成容器,而不是新建一个。Codeman 只 `exec` 进去,绝不启动、停止、重启或删除它;一个被接管的容器可以在不同目录下支撑多个 case,**复制一个已有 case** 会用同一容器上的兄弟 case 预填表单。多用户模式下仅管理员可用,因为容器的挂载属于启动它的人。
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。 - **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上用 `docker load` 载入,再导入到一个新案例即可继续。
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。 - **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。 前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。
@@ -497,6 +516,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。 - **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。 - **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。 - **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
- **完整的回滚历史**:加载远程会话的历史时,会通过一次有上限的 ssh 往返直接从远程 tmux 读取,所以重新附着或切换标签之后,会话依然保有完整的回滚缓冲区,历史与实时屏幕衔接处也不会丢行。
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。 在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
@@ -649,7 +669,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
### 网络与访问 ### 网络与访问
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认) - **默认仅环回** —— 服务器程序本身绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全(引导式安装器会询问网络访问方式,并替你配置绑定和密码)。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志) - **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器 - **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权 - **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
@@ -667,7 +687,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
### 输入、文件与响应头 ### 输入、文件与响应头
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制 - **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行 - **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行。HTML 唯一会被渲染的地方是文件查看器的网页视图:一个独立的 `/html-view/<capability>/` 地址,只覆盖该文件所在的文件夹,响应带 `Content-Security-Policy: sandbox` 且不含 `allow-same-origin`,因此页面是不透明源,永远读不到 Codeman 的 cookie,也驱动不了它的 API;隐藏文件夹和范围过大的根目录(`/`、主目录、`/tmp`)会被拒绝,退出登录时这些凭证也会被吊销
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS - **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
### 供应链与隔离 ### 供应链与隔离
@@ -701,7 +721,7 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
| 快捷键 | 动作 | | 快捷键 | 动作 |
| ------------------------------- | -------------------------------------------------------- | | ------------------------------- | -------------------------------------------------------- |
| `Ctrl/Cmd+W` | 杀掉当前会话 | | `Ctrl+W` | 不是 Codeman 快捷键:它会到达终端(删除前一个词)。**Close Session** 默认没有按键,可在 App Settings → Shortcuts 里绑定 |
| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 | | `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 |
| `Ctrl/Cmd+Tab` | 下一个会话 | | `Ctrl/Cmd+Tab` | 下一个会话 |
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 | | `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
@@ -714,6 +734,10 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
| `Ctrl/Cmd+L` | 清屏 | | `Ctrl/Cmd+L` | 清屏 |
| `Ctrl+Shift+R` | 恢复终端尺寸 | | `Ctrl+Shift+R` | 恢复终端尺寸 |
| `Ctrl+Shift+V` | 切换语音输入 | | `Ctrl+Shift+V` | 切换语音输入 |
| `Ctrl+Shift+G` | 打开或关闭平铺网格 |
| `Alt+Shift+方向键` | 聚焦左、右、上、下方的窗格 |
| `Ctrl+Shift+方向键` | 把聚焦的窗格移动一格(移进空格子,或与邻居交换) |
| `Alt+Shift+Enter` | 放大聚焦的窗格,或恢复网格 |
| `Ctrl/Cmd +` / `-` | 字体大小 | | `Ctrl/Cmd +` / `-` | 字体大小 |
| `Ctrl/Cmd+?` | 键盘帮助 | | `Ctrl/Cmd+?` | 键盘帮助 |
| `Shift+Enter` | 插入换行(发送到终端) | | `Shift+Enter` | 插入换行(发送到终端) |
@@ -731,19 +755,23 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
### 智能体技能(从这里开始) ### 智能体技能(从这里开始)
这一节的所有内容也打包成了一个 **Claude Code 技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md)。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。 这一节的所有内容也打包成了一个**智能体技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md),采用 Claude Code、Codex、Gemini CLI、OpenCode、Grok、OMP 以及大多数其他智能体 CLI 都能读取的 `SKILL.md` 格式。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。
<p align="center">
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="一次真实的 codeman 技能运行:在 Claude Code 里输入一句简短的提示,平铺网格开机,然后一个跑在本地 qwen 模型上的 DeepSeek Harness 工作会话和一个 Claude Code 工作会话作为新窗格开机,主会话到两者之间画出派生连线" width="900"></a>
</p>
#### 第 1 步:安装 #### 第 1 步:安装
| 方式 | 命令 | 范围 | | 方式 | 命令 | 范围 |
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用 | | Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用([skills.sh](https://skills.sh));给 pi 装技能就用这个方式 |
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) | | Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 | | 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
| 内置 CLI | `codeman skill install --case <name>` | 仅一个 case | | 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | 每次在某个 case 创建 Claude 会话时自动注入(`agentSkillEnabled`,跨设备同步,默认关闭) | | Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,默认关闭) |
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装,并且绝不会碰你自己写的 `skills/codeman`。 `codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装(带 `--case` 时,删除该 case 里所有由 Codeman 管理的副本,不论它在哪个 CLI 的目录里),并且绝不会碰你自己写的 `skills/codeman`。
#### 第 2 步:开口要 #### 第 2 步:开口要
@@ -796,7 +824,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。 - **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。 - **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个 `.claude/` 目录的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。 ⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
--- ---
@@ -933,6 +961,76 @@ codeman tui --list # 带编号的会话列表(管
codeman tui 3 # 附着到该列表里的第 3 个会话 codeman tui 3 # 附着到该列表里的第 3 个会话
``` ```
### `codeman agent`:每种 CLI 模式都能用的会话间命令
上面的技能靠 Codeman 为每个会话写入的一段前导脚本,用 `curl` 和 `jq` 驱动 API。在它够不着的地方(`pi` 或 `shell` 会话、没有 `jq` 的机器),同样的环境依然存在(每个面板里都导出了 `CODEMAN_MUX=1`、`CODEMAN_SESSION_ID`、`CODEMAN_API_URL`),所以 `codeman agent` 把同一批动词打包成了命令。它只是 [API](#api) 一节所列端点之上的一层薄客户端:没有新路由,没有新的传输方式,认证和归属规则不变。在 case 的 `AGENTS.md` 里写一行就够了:*"other sessions: `codeman agent --help`"*。
```bash
codeman agent ls # 列出会话;* 标记当前这个
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start,并在该模式有就绪标记时等到输入框出现
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = 默认信号集
codeman agent read "$SID" # 最后一条回答(按服务端对该模式的读取方式)
codeman agent read "$SID" --tail 3000 # 终端尾部,已去掉 ANSI(所有模式)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # 没有 hook 的模式(opencode、pi 等):让它分两半输出标记……
codeman agent wait "$SID" --match WORKDONE_4711 # ……然后等拼接后的形式,提示本身的回显里永远不会出现它
codeman agent interrupt "$SID" # 只发一个 ESC,对话保持完整
codeman agent rm "$SID" # 除当前会话外的任何会话
```
这些规则由命令强制执行,而不只是写在文档里:在 Codeman 会话之外它们拒绝运行,也绝不猜测 URL;`send` 只发送可打印文本加 Enter(像 `Ctrl+C` 这样的控制字节在 opencode 里就是 `app_exit`,所以 ESC 只以 `interrupt` 的形式存在,而且它从不追加 Enter);`rm` 拒绝空 id、无法证明不是自己的 id,以及任一方向的前缀匹配。id 可以用 `ls` 打印的 8 字符前缀(通过列表解析;有歧义的前缀会被拒绝,短于 8 个字符的 id 在所有动词上都会被拒绝)。以 `-` 开头的提示要放在 `--` 之后(`send "$SID" -- "- fix the bug"`)。
你发出的提示的回显也算输出:如果 `--match` 标记原样出现在提示里,它会立刻匹配,工作会话还什么都没做,所以提示要让它分两半输出。主机正在休眠的远程会话,对不等待结果的 `send` 会回答 `buffered`(Codeman 唤醒主机,等面板恢复后再敲入提示)或 `dropped`(超出唤醒缓冲区的上限,什么都不会敲入:退出码 `1`)。退出码:`0` 已送达/已匹配/收到信号,`1` 错误,`2` 超时,`3` 工作会话已退出或等待在没有回答的情况下结束(`delivered:false`、`ended:true`),`4` 被拒绝。`spawn` 在 stdout 上只打印 id(说明文字走 stderr),所以 `SID=$(…)` 恰好拿到 id。`--json` 让每个动词打印响应信封里的 `data`。在没有 hook 信号的模式上使用 `--until stop`,会原样透传服务端的 400:那里的答案是标记路径(`--match`),和技能完全一样。
### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端:没有新路由,认证和归属规则不变。
```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):为长时间等待调高默认 60 秒的工具超时
[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 只给 MCP 服务器一个最小环境;这两个变量让它知道自己运行在哪个 Codeman 会话里
tool_timeout_sec = 120
```
JSON 形式适用于 Claude Desktop、Cursor 和 Gemini CLI。服务器设置了密码时,在 `env` 里加上 `"CODEMAN_PASSWORD"`(或者让它读取数据目录里的 `.env`)。如果 PATH 上没有 `codeman` 命令(没走安装器的 git 检出),改用 `node /path/to/Codeman/dist/index.js mcp`。
| 工具 | 作用 |
| ---- | ---- |
| `list_sessions` | 你能看到的每个会话,附带运行模式、状态、文件夹,以及这些工具是否可以给它发提示 |
| `start_session` | 在一个 case 里(全新的临时文件夹或已有的 case)用任意已安装的 CLI 起一个新工作会话;等到它能接收提示为止 |
| `send_prompt` | 输入一行提示并按 Enter;可选择等到这一回合结束 |
| `wait_for_session` | 等待一个信号(`stop`、`blocked`、`idle`、`working`、`exit`)或输出里的一个字面标记 |
| `read_output` | 最后一条回答、整段对话,或终端的最后若干字节 |
| `interrupt_session` | 一次 Escape:停止当前回合,保留对话 |
| `delete_session` | 关闭一个工作会话(它的 case 文件夹会保留) |
这些规则由服务器强制执行,而不只是写在文档里:
- **它绝不猜测服务器。** URL 来自 `CODEMAN_API_URL` 或 `--url`;没有配置时它照样启动并列出工具,每次调用都会说明该如何配置。
- **只动自己的会话。** 发提示、中断和删除只限于这个服务器自己启动的会话(以及它运行在某个 Codeman 会话里时,该会话自己的工作会话)。列出、读取和等待对所有会话都可用。`codeman mcp --allow-any-session` 会解除这个限制,适合想让助手驱动自己现有标签的用户。它永远不会对自己所在的会话下手。
- **等待时长适配客户端。** 一次等待默认 45 秒(Codex 默认在 60 秒时切断 MCP 工具),上限 600 秒;返回 `timed_out` / `still_working` 时再调用一次即可。
- **两个协议时代都支持。** 它既能应答 2026-07-28 版本(每个请求带 `_meta`、`server/discover`),也能应答 2024-11-05 到 2025-11-25 的 `initialize` 握手。
### Hook(事件*回流*到 Codeman) ### Hook(事件*回流*到 Codeman)
Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。 Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。
+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-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts', 'test/tab-layout-editing.browser.test.ts',
'test/tab-rail-search.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-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts', 'test/session-options-responsive.browser.test.ts',
'test/inline-rename.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/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts', 'test/split-pane-terminal.browser.test.ts',
'test/terminal-tile-scroll.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/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts', 'test/key-tester.browser.test.ts',
'test/webhook-settings.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/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts', 'test/run-mode-menu-scroll.browser.test.ts',
'test/markdown-anchor-links.browser.test.ts', 'test/markdown-anchor-links.browser.test.ts',
'test/fullscreen-tab-switch-capture.browser.test.ts',
]; ];
/** /**
+209 -6
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`, > 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/sessions/:id/tail-file` (SSE), `GET /api/download`,
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the > `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect),
> `GET /ws/sessions/:id/terminal` WebSocket upgrade. > `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 > **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
> `GET /api/screenshots/:name` keep working but log a one-time warning on first > `GET /api/screenshots/:name` keep working but log a one-time warning on first
@@ -87,6 +88,93 @@ the HTTP status.
Adding a new error code is non-breaking; removing or renaming one is a major change. Adding a new error code is non-breaking; removing or renaming one is a major change.
## Cron jobs
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
is the unversioned alias. These routes use the response envelope above; the table
lists the value inside `data` on success.
| Method | Path | Request body | Response `data` |
| --- | --- | --- | --- |
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
### Job request fields
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
are required according to the selected prompt and schedule:
| Field | Type / validation |
| --- | --- |
| `name` | String, 1–200 characters |
| `agentType` | A supported session mode (including `shell`) |
| `workingDir` | Existing, allowed working-directory path |
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
| `promptMode` | `inline_text` or `prompt_file_path` |
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
| `inputMode` | `paste` or `typed` |
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
| `enabled` | Boolean |
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
| `notes` | Optional string, at most 2000 characters |
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
policy, and does not change the schedule. `activeAgents` counts live sessions of
the same agent type, excluding sessions created by this job.
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
also closes the previous run's session before launching, even if it is still working.
### Job and run response fields
`CronJob` contains the request fields plus server-maintained `id`, optional
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
Run times are also Unix milliseconds. Status is one of `created`,
`session_started`, `prompt_sent`, `failed`, or `skipped`.
Prompt delivery continues asynchronously after session launch, so `Run Now` can
return `session_started` before the prompt is sent. Read run history for subsequent
updates, but do not assume a terminal status will follow: if the session is closed
during the readiness wait or the server restarts before delivery, the run can remain
`session_started` indefinitely with `finishedAt: null`.
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
launch-command job without the required privilege grant. Invalid definitions or
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
`cron:runUpdated` SSE events.
## Long-polling (agent wait) ## Long-polling (agent wait)
Three calls block until something happens instead of answering immediately. They Three calls block until something happens instead of answering immediately. They
@@ -456,19 +544,30 @@ 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`, What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`, with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's `truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older
geometry was read. The capture runs synchronous tmux calls on the server; the 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`. `Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning | | 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. | | `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). | | `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) ## 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 in modes that never receive the claude-only skill preamble. 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 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`) ## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
@@ -487,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 adopted container (`owned: false`) mounts nothing, so its agent can open the file
only if the container itself exposes that host path. 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`) ## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a A create request may name the session that spawned it, which the web UI draws as a
@@ -538,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 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. `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 ## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs, Cross-session queue of prompts waiting on a human (permission dialogs,
File diff suppressed because one or more lines are too long
+12
View File
@@ -128,6 +128,18 @@ sure its row is one the agent cannot write.
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session. `test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
### Where the agent skill lands
`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed.
| CLI | Skill directory |
| --- | --- |
| claude | `.claude/skills/codeman` |
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
| pi, shell | none |
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
## The newline chord ## The newline chord
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think. `capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
+2 -2
View File
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
Claude session in `~/proj` and tell it to update dependencies and open a PR."_ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`). - **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
- **API**: `/api/cron/jobs*` and `/api/cron/runs`. - **API**: `/api/cron/jobs*` and `/api/cron/runs`.
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`, - **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`, types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
### In the browser ### In the browser
1. Click **⏰ Cron** in the header. 1. Click **⏰ Cron** in the bottom toolbar.
2. Click **+ New Job**. 2. Click **+ New Job**.
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a 3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
**prompt** (inline text or a file path), pick a **schedule**, and leave **prompt** (inline text or a file path), pick a **schedule**, and leave
+8 -3
View File
@@ -163,9 +163,14 @@ respawn, Ralph, the orchestrator, search, and admin. Each route module carries a
If the caller is an agent running _inside_ a Codeman session, install the packaged If the caller is an agent running _inside_ a Codeman session, install the packaged
agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install (`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
[--case <name>]`, or the synced `agentSkillEnabled` App Setting for automatic [--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
per-case injection on Claude session create). The skill carries the guard, the automatic per-case injection on session create, into the directory each CLI scans for
safety rules, and verified wait/orchestration recipes. 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: The common ones:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

+68
View File
@@ -0,0 +1,68 @@
# Media assets: videos and GIFs
Codeman produces a lot of video: README GIFs, website loops, release-note clips, promo videos and their drafts, raw screen captures. This page says where each kind lives and which of them belong in git.
## The rule
**Only media that a public page embeds is pushed to GitHub. Everything else stays on the maintainer's machine, in the local media library.**
| Kind | Where it lives | On GitHub? |
| --- | --- | --- |
| GIFs the README, the wiki or a published article embeds | `docs/images/` on `master` | yes |
| Release-note and media-kit clips | the orphan `design-assets` branch | yes, but never on `master` |
| getcodeman.com videos and GIFs | `pr/website/assets/` (local, deployed to Cloudflare Pages) | no |
| Masters, promo videos, drafts, shorts, feature clips, raw captures | `pr/media/` (the local media library) | no |
| Remotion render inputs for the promo videos | `scripts/remotion/public/` on the unpushed promo branch | no |
`.gitignore` enforces it: `*.mp4`, `*.webm`, `*.mov`, `*.m4v`, `*.mkv`, `*.avi` and `*.gif` are ignored everywhere except `docs/images/*.gif`, plus `scripts/remotion/public/`, whose render inputs the promo branch tracks. A video that really must be committed needs `git add -f`, which makes the exception visible in review.
A GIF that leaves `docs/images/` stays in git history, so removing it does not shrink a clone. The rule keeps new weight out and the tree honest about what is actually used.
## Tracked GIFs (`docs/images/`)
| File | Embedded in | Shows |
| --- | --- | --- |
| `tiles-crt-stats-1600-20261010.gif` | README hero (en + zh-CN) | six live agents in the tile grid, powering on and off with the CRT animation, header stats on |
| `tile-grid-20261009.gif` | README, Tile Grid section | the Tiles button opening six live sessions side by side, and closing them |
| `zerolag-demo-20260728.gif` | README, wiki `Input-And-Voice`, `packages/xterm-zerolag-input/README.md`, CHANGELOG, getcodeman.com docs | instant local echo next to slow server echo, on two phones |
| `codeman-tab-states-20261010.gif` | README, Tab Alerts section | annotated tab states: working, blocked on a question, idle |
| `codeman-skill-crt-20261010.gif` | README, agent skill section | one prompt to Claude Code spawning and driving two worker sessions through the skill |
| `subagent-demo-20260724.gif` | the "mission control" article on dev.to, Hashnode and Medium (hotlinked from `master`) | subagent windows fanning out |
Before removing one, search for hotlinks: `README*.md`, `docs/wiki/`, `packages/*/README.md`, `CHANGELOG.md`, the site (`pr/website/`), and published articles, which link `raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/<file>` and break silently.
On 2026-10-13, seven GIFs that no page embedded left `docs/images/` and moved into the library: `codeman-demo.gif`, `codeman-skill-20261010.gif`, `subagent-demo-new.gif`, `subagent-demo-opt.gif`, `tab-alerts-glow-20260815.gif`, `tiles-crt-20261010.gif`, `tiles-crt-stats-20261010.gif`.
### Adding or replacing a README GIF
1. Keep the master (the lossless MP4 or the full-size GIF) in `pr/media/clips/<feature>/`, or in the project folder it came from.
2. Commit only the size the README shows, named `<feature>-<YYYYMMDD>.gif`, and keep it lean: the current ones are 0.8 to 7 MB.
3. A replaced GIF leaves `docs/images/` in the same commit, unless something outside the repo hotlinks it (see above).
## Website media (getcodeman.com)
The site is built and deployed from `pr/website/` (gitignored) with `pr/deploy-site.sh`. Its videos and GIFs sit in `pr/website/assets/` because that directory is what gets uploaded: they are web cuts (1280 to 1600 px wide, high CRF, posters next to them) derived from masters in the library. `pr/site-notes.md` records how each one was cut and from which master. A few superseded files stay deployed on purpose, so links that point at them keep resolving.
## Release notes and the media kit (`design-assets` branch)
GitHub releases and the Announcements discussions embed clips from the orphan `design-assets` branch (`release-1.40/`, `release-1.41/`, `media-kit-2026-10/`). They are referenced by a commit-pinned raw URL, so the branch can change later without breaking old notes, and none of it ever touches `master`.
## The local media library (`pr/media/`)
`pr/` is gitignored, so the library is never pushed. It holds every master, every finished promo cut and every superseded draft in one tree:
```
pr/media/
README.md curated guide: what each collection is, which cut is current, where it is published
INVENTORY.md generated list of every file (size, length, resolution, fps, date, published-or-not)
inventory.mjs regenerates INVENTORY.md: node pr/media/inventory.mjs
videos/<production>/ finished promo videos; superseded renders in drafts/
shorts/<episode>/ per-feature cuts of a promo; older sets in drafts/
clips/<feature>/ feature loops and GIF masters (README / site / release clips start here)
crt-gifs/ CRT tile-animation loops
files-one-click-20261011/ the Files panel loop project (own README)
screenshots-hq/ high-res stills and their capture masters
raw-captures/ unedited screen recordings, safe to prune
```
Two working directories keep files the library only archives, because tools read them from fixed paths: `pr/website/assets/` (the deploy upload) and the promo worktree's `scripts/remotion/public/` (Remotion's `staticFile()` inputs). Renders still land in `scripts/remotion/out/` and are filed into `pr/media/videos/<production>/` once checked. A finished render never goes to `tmp/`.
+33
View File
@@ -0,0 +1,33 @@
# Releasing Codeman (the COM procedure)
> Moved from the root `CLAUDE.md`, which keeps a pointer here. What SemVer covers is defined in `docs/versioning-policy.md`. The root file's Session Safety rules (branch check, explicit paths, never `git add -A`) apply to every step.
When user says "COM":
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
2. **Create a changeset file** (no interactive prompts). Write a `.md` file in `.changeset/` with a random filename:
```bash
cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
---
"aicodeman": patch
---
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
CHANGESET
```
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
8. **Publish the new version to the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so.
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
+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 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. 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 ## File access over SSH
A remote case's `workingDir` is an absolute path on the **remote** host 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 | | `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 | | `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` | | `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 | | `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 ⚠️ 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 file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above. 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 ### SSE log‑tail route — intentional extra read roots
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log 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 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). - **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. - **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. - **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. 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 ### Alternatives considered & rejected
| Source | Why not | | Source | Why not |
+15 -7
View File
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
dependencies and open a PR.* dependencies and open a PR.*
The ⏰ **Cron** header button is opt-in. Turn it on in The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
**App Settings → Header & Panels**. **App Settings → Header & Panels → Scheduling**.
## Creating a job ## Creating a job
@@ -96,11 +96,19 @@ The skip policy has the details you would want it to have:
Every fire is recorded per job, with a status: Every fire is recorded per job, with a status:
| Status | Meaning | | Status | Meaning |
| --------- | -------------------------------------------------------------------- | | ----------------- | ----------------------------------------------------------------- |
| `created` | The run started and a session was created. | | `created` | The run record was created; the session has not started yet. |
| `skipped` | The concurrency policy blocked it. Not counted as a run. | | `session_started` | The session started; prompt delivery is still pending. |
| `failed` | The prompt could not be resolved, or the working directory was gone. | | `prompt_sent` | The prompt was sent to the session. |
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
History records whether the session started and the prompt was delivered, **not whether
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
If the session is closed during the readiness wait or the server restarts before
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
clients must not poll forever waiting for a terminal status.
The schedule is advanced **before** the session launches, so a slow start cannot cause the The schedule is advanced **before** the session launches, so a slow start cannot cause the
same job to re-trigger. same job to re-trigger.
+69 -15
View File
@@ -4,26 +4,34 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
supervising other sessions.** supervising other sessions.**
Three routes. In a Claude session, start with the skill. In any other CLI mode, use the Three routes. Start with the skill, whichever CLI the agent is. Where the skill does not
`codeman agent` commands. Raw HTTP is there for everything else. reach (a pi or shell session), use the `codeman agent` commands. Raw HTTP is there for
everything else.
## The agent skill ## The agent skill
A Claude Code skill that teaches the agent the whole API, so you ask in plain English A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP
instead of pasting endpoint documentation into prompts. and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
English instead of pasting endpoint documentation into prompts.
### Install it ### Install it
| How | Command | Scope | | How | Command | Scope |
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | | ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. | | Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. The way to give it to pi. |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. Pick this or `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). | | Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. Pick this or `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). |
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. | | Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
| Bundled CLI | `codeman skill install --case <name>` | One case. | | Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. | | Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Not pi: a project skill makes pi ask to trust the folder at startup. Off by default. |
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a `codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
`skills/codeman` you wrote yourself. Codeman-managed copy in that case), and never touches a `skills/codeman` you wrote yourself.
Two things to know when the agent is **Codex**: its default sandbox blocks network access
even to this machine, so the skill needs **Bypass approvals and sandbox** (App Settings,
Codex); and Codex 0.162 runs its commands in a shared background process, whose Codeman
variables can belong to the session that started it. The skill checks for both and says so
instead of acting.
### Then just ask ### Then just ask
@@ -62,12 +70,12 @@ completion signals.
## The `codeman agent` commands ## The `codeman agent` commands
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An The skill drives the API with `curl` and `jq`. Where it does not reach (a pi or shell
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing session, a machine without `jq`), the agent runs in the same environment, so `codeman agent`
that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is packages the same verbs as shell commands. It is a thin client over the endpoints in
a thin client over the endpoints in [the manual path](#the-manual-path), so auth and [the manual path](#the-manual-path), so auth and ownership apply unchanged, and it refuses to
ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"other sessions:
case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*. `codeman agent --help`"*.
```bash ```bash
codeman agent ls # sessions; * marks this one codeman agent ls # sessions; * marks this one
@@ -90,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 - **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`. 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 manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
+26
View File
@@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain:
Each route module documents its own endpoints in its file header. Each route module documents its own endpoints in its file header.
## Cron jobs
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
versioned endpoints are:
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/v1/cron/jobs` | List jobs |
| POST | `/api/v1/cron/jobs` | Create a job |
| GET | `/api/v1/cron/jobs/:id` | Read a job |
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
| GET | `/api/v1/cron/runs` | Read all accessible run history |
Responses use the envelope above: job lists/history have arrays in `data`, a
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
`session_started` and `prompt_sent` describe launch/delivery, not task success.
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
lists all request and response fields, validation, and ownership restrictions.
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
## Long-polling instead of polling ## Long-polling instead of polling
Three calls block until something happens, so an agent driving Codeman from a shell can wait Three calls block until something happens, so an agent driving Codeman from a shell can wait
+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 are waiting to resume. This is the single most useful setting for overnight runs on a
subscription plan. 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 ## The plan usage chip
**Claude only.** A header chip showing live subscription usage, on by default on desktop and **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 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, 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 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 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 Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
editing the last queued message and walking the prompt stack. 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 On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
into the Run dropdown. 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 ## 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 - **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, 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. 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 - **Tap a file path** an agent printed and the file-preview overlay opens (Markdown rendered,
log viewer. Works in scrolled-up transcript too. 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 - 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, 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. because on a phone it is the only interaction that matters.
+4 -1
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. | | 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. | | 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). | | 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. | | Overview Home Screen | The phone home screen. On by default. |
### Animations ### Animations
@@ -145,7 +146,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Allowed Tools | The list used by the explicit mode. | | Allowed Tools | The list used by the explicit mode. |
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. | | Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. | | Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). | | Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. | | Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
| Nice priority / value | Runs agent processes at a lower CPU priority. | | Nice priority / value | Runs agent processes at a lower CPU priority. |
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. | | Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
@@ -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_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_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_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_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_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. | | `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. |
+14 -5
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. | | **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. | | **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` 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 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: Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open. - **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 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 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. recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent - **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 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/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 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 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 (PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
too. Other CLIs scroll locally. 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. - **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies. `Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane - **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
@@ -258,7 +267,7 @@ Two extras depending on the device:
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) | | Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) | | Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) | | Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) | | Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) | | Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) | | Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
| File Viewer | Header | [Working With Files](Working-With-Files) | | File Viewer | Header | [Working With Files](Working-With-Files) |
+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. render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a 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 working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail page. The exception is a plain text file (a log, `.txt`, `.json`, code) inside the workspace
viewer instead, which follows a file that is still being written. 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 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 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, 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 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`, 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 `.bmp`) say so rather than failing silently.
rendered, so nothing served this way can execute in the page.
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 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. 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 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 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. 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 ## Generated artifacts
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "1.41.0", "version": "1.41.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "1.41.0", "version": "1.41.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "aicodeman", "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.", "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", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "codeman", "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.", "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": { "author": {
"name": "Ark0N", "name": "Ark0N",
"url": "https://github.com/Ark0N" "url": "https://github.com/Ark0N"
+29 -12
View File
@@ -1,11 +1,13 @@
--- ---
name: codeman name: codeman
description: >- description: >-
Drive Codeman, the session manager this agent is running inside, over its HTTP API: Drive Codeman, the session manager this agent is running inside, over its HTTP API,
list sessions, start worker sessions, send them prompts, block until they finish from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
(wait / wait-output / send-and-wait), read their output, and clean up; where Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
available, message claude workers directly (Claude Code cross-session messaging). CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
Use when asked to orchestrate or parallelize work across Codeman sessions, watch read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
another session, or start and manage workers. Only usable inside a Codeman-managed another session, or start and manage workers. Only usable inside a Codeman-managed
session (CODEMAN_MUX=1); refuse to act otherwise. session (CODEMAN_MUX=1); refuse to act otherwise.
--- ---
@@ -29,17 +31,31 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Workers in **every other mode** never receive this preamble, but they have the same This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
same endpoints, with the guards below (no control bytes, no self-delete, no guessed it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
URL) enforced in code. That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
no guessed URL) enforced in code, and it is also your own way through when this
machine has no `jq` for the preamble below.
## 0. Guard and bootstrap ## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive. you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a ⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
the next call, and `$$` is a different pid. **The filesystem does survive**, so write the next call, and `$$` is a different pid. **The filesystem does survive**, so write
@@ -47,8 +63,9 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
single most likely way to break a run). single most likely way to break a run).
**Codeman seeds the preamble file for you** when it spawns a claude session (server **Codeman seeds the preamble file for you** when it spawns your session (claude since
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
usually nothing at all: these are the two lines every
later call opens with, and your first REAL call performs them anyway: later call opens with, and your first REAL call performs them anyway:
```bash ```bash
+18
View File
@@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman';
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`; const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
const MIRROR = `${PLUGIN_DIR}/skills/codeman`; const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json']; 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 check = process.argv.includes('--check');
const { version } = JSON.parse(readFileSync('package.json', 'utf8')); 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'); 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) { if (drift.length === 0) {
console.log(`plugin in step: mirror identical, manifests at ${version}`); console.log(`plugin in step: mirror identical, manifests at ${version}`);
} else if (check) { } else if (check) {
+29 -12
View File
@@ -1,11 +1,13 @@
--- ---
name: codeman name: codeman
description: >- description: >-
Drive Codeman, the session manager this agent is running inside, over its HTTP API: Drive Codeman, the session manager this agent is running inside, over its HTTP API,
list sessions, start worker sessions, send them prompts, block until they finish from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
(wait / wait-output / send-and-wait), read their output, and clean up; where Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
available, message claude workers directly (Claude Code cross-session messaging). CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
Use when asked to orchestrate or parallelize work across Codeman sessions, watch read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
another session, or start and manage workers. Only usable inside a Codeman-managed another session, or start and manage workers. Only usable inside a Codeman-managed
session (CODEMAN_MUX=1); refuse to act otherwise. session (CODEMAN_MUX=1); refuse to act otherwise.
--- ---
@@ -29,17 +31,31 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md). direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Workers in **every other mode** never receive this preamble, but they have the same This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
same endpoints, with the guards below (no control bytes, no self-delete, no guessed it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
URL) enforced in code. That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
no guessed URL) enforced in code, and it is also your own way through when this
machine has no `jq` for the preamble below.
## 0. Guard and bootstrap ## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive. you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a ⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
the next call, and `$$` is a different pid. **The filesystem does survive**, so write the next call, and `$$` is a different pid. **The filesystem does survive**, so write
@@ -47,8 +63,9 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
single most likely way to break a run). single most likely way to break a run).
**Codeman seeds the preamble file for you** when it spawns a claude session (server **Codeman seeds the preamble file for you** when it spawns your session (claude since
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
usually nothing at all: these are the two lines every
later call opens with, and your first REAL call performs them anyway: later call opens with, and your first REAL call performs them anyway:
```bash ```bash
+15 -3
View File
@@ -9,7 +9,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs'; import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises'; 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 { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js'; import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js'; import { validateSessionFilePath } from './web/route-helpers.js';
@@ -148,6 +149,11 @@ class AttachmentRegistry {
export const attachmentRegistry = new 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 { export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, '')); return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
} }
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
requestedPath: string, requestedPath: string,
options: RegisterExternalAttachmentOptions = {} options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> { ): 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'); throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
} }
const resolved = await (options.remote const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes) ? 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 // COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else. // path before doing anything else.
+13 -8
View File
@@ -253,20 +253,21 @@ export interface RequestOptions {
headers?: Record<string, string>; headers?: Record<string, string>;
/** Socket timeout; long-polls pass their own timeout plus headroom. */ /** Socket timeout; long-polls pass their own timeout plus headroom. */
timeoutMs?: number; 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>; export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
/** Every request carries these; they are ignored on endpoints that do not read them. */ /** Every request carries these; they are ignored on endpoints that do not read them. */
export function baseHeaders(ctx: AgentContext): Record<string, string> { export function baseHeaders(ctx: AgentContext): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = { Accept: 'application/json' };
Accept: 'application/json', // Tags sessions this caller spawns as its children (lineage in the web UI).
// 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
// 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
// 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
// may create one (see agentSpawn), never anything else. // any session (`codeman mcp` launched by a desktop MCP client), which has no self.
'X-Codeman-Parent-Session': ctx.selfId, if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId;
};
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined; const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
if (authorization) headers.Authorization = authorization; if (authorization) headers.Authorization = authorization;
return headers; return headers;
@@ -296,10 +297,14 @@ export const httpRequest: ApiRequest = (ctx, options) => {
rejectUnauthorized: false, rejectUnauthorized: false,
headers, headers,
timeout: options.timeoutMs ?? 30_000, timeout: options.timeoutMs ?? 30_000,
signal: options.signal,
}, },
(res) => { (res) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk)); 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', () => { res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8'); const text = Buffer.concat(chunks).toString('utf-8');
let json: ApiEnvelope | undefined; let json: ApiEnvelope | undefined;
+123 -13
View File
@@ -18,8 +18,18 @@ import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js'; import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js'; import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js'; import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js'; import {
agentSkillDir,
agentSkillDirFor,
CLAUDE_AGENT_SKILL_DIR,
installAgentSkillInto,
removeAgentSkillFrom,
type AgentSkillApplyResult,
} from './hooks-config.js';
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 { registerAgentCommands } from './cli-agent.js';
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
import { getSessionManager } from './session-manager.js'; import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js'; import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js'; import { getRalphLoop } from './ralph-loop.js';
@@ -140,7 +150,8 @@ export function resolveCliCasePath(name: string): string {
/** /**
* Resolve where `skill install` / `skill uninstall` operate. Global is * Resolve where `skill install` / `skill uninstall` operate. Global is
* `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new * `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new
* session); `--case <name>` targets `<case>/.claude/skills/codeman`, resolved through * session); `--case <name>` targets `<case>/<skillDir>/codeman` for the given skills
* directory (default: Claude Code's, `<case>/.claude/skills/codeman`), resolved through
* `resolveCliCasePath()` above. The web server's automatic per-case injection * `resolveCliCasePath()` above. The web server's automatic per-case injection
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool * (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
* and stays single-user. * and stays single-user.
@@ -151,17 +162,18 @@ export function resolveCliCasePath(name: string): string {
*/ */
export function resolveSkillTargetPath(options: { export function resolveSkillTargetPath(options: {
case?: string; case?: string;
skillDir?: AgentSkillDir;
}): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } { }): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } {
if (options.case) { if (options.case) {
const casePath = resolveCliCasePath(options.case); const casePath = resolveCliCasePath(options.case);
if (!existsSync(casePath)) return { missingCase: casePath }; if (!existsSync(casePath)) return { missingCase: casePath };
return { target: join(casePath, '.claude', 'skills', 'codeman') }; return { target: agentSkillDirFor(casePath, options.skillDir ?? CLAUDE_AGENT_SKILL_DIR) };
} }
return { target: join(homedir(), '.claude', 'skills', 'codeman') }; return { target: join(homedir(), '.claude', 'skills', 'codeman') };
} }
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */ /** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
function resolveSkillTarget(options: { case?: string }): string { function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }): string {
const resolved = resolveSkillTargetPath(options); const resolved = resolveSkillTargetPath(options);
if (resolved.missingCase !== undefined) { if (resolved.missingCase !== undefined) {
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`)); console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
@@ -170,8 +182,44 @@ function resolveSkillTarget(options: { case?: string }): string {
return resolved.target; return resolved.target;
} }
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */ /**
function reportSkillResult(result: AgentSkillApplyResult, target: string): void { * The skills directory `skill install --mode <mode>` writes into, or why it cannot: a
* mode the registry gives no skill, or a user-level install for a CLI other than Claude
* Code (each CLI keeps its user-level skills somewhere else, and `npx skills add`
* already knows every one of those places).
*/
export function resolveSkillInstallDir(options: {
mode?: string;
case?: string;
}): { skillDir: AgentSkillDir; error?: undefined } | { skillDir?: undefined; error: string } {
const mode = options.mode ?? 'claude';
const cli = getCli(mode);
if (!cli) return { error: `Unknown mode: ${mode}` };
const skillDir = agentSkillDir(cli.capabilities);
if (!skillDir) return { error: `Mode ${mode} takes no Codeman agent skill` };
if (!options.case && skillDir !== CLAUDE_AGENT_SKILL_DIR) {
return {
error: `A user-level install for ${mode} goes through the skills installer: npx skills add Ark0N/Codeman --skill codeman -g (or pass --case <name>)`,
};
}
return { skillDir };
}
/**
* Every distinct skills directory the registry can inject into, so
* `skill uninstall --case` removes whatever any CLI's session left there.
*/
export function registryAgentSkillDirs(): AgentSkillDir[] {
const dirs = new Set<AgentSkillDir>();
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) dirs.add(dir);
}
return [...dirs];
}
/** An AgentSkillApplyResult in words, and whether it counts as success. */
function describeSkillResult(result: AgentSkillApplyResult, target: string): { ok: boolean; text: string } {
const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = { const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = {
installed: { ok: true, text: `Agent skill installed: ${target}` }, installed: { ok: true, text: `Agent skill installed: ${target}` },
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` }, refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
@@ -187,7 +235,12 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void
text: `${target} (or its parent) is a symlink, refusing to write through it.`, text: `${target} (or its parent) is a symlink, refusing to write through it.`,
}, },
}; };
const message = messages[result]; return messages[result];
}
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
const message = describeSkillResult(result, target);
if (message.ok) { if (message.ok) {
console.log(palette.ok(`✓ ${message.text}`)); console.log(palette.ok(`✓ ${message.text}`));
} else { } else {
@@ -205,9 +258,19 @@ skillCmd
.description('Install the agent skill globally (~/.claude/skills/codeman) or into one case') .description('Install the agent skill globally (~/.claude/skills/codeman) or into one case')
.option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)') .option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)')
.option('-c, --case <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)') .option('-c, --case <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)')
.action(async (options: { global?: boolean; case?: string }) => { .option(
'-m, --mode <mode>',
'With --case: install where that run mode looks for skills (codex: <case>/.agents/skills/codeman)',
'claude'
)
.action(async (options: { global?: boolean; case?: string; mode?: string }) => {
try { try {
const target = resolveSkillTarget(options); const resolved = resolveSkillInstallDir(options);
if (resolved.error !== undefined) {
console.error(palette.err(`✗ ${resolved.error}`));
process.exit(1);
}
const target = resolveSkillTarget({ case: options.case, skillDir: resolved.skillDir });
reportSkillResult(await installAgentSkillInto(target), target); reportSkillResult(await installAgentSkillInto(target), target);
} catch (err) { } catch (err) {
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`)); console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
@@ -217,13 +280,41 @@ skillCmd
skillCmd skillCmd
.command('uninstall') .command('uninstall')
.description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)') .description('Remove Codeman-managed agent skill copies (never touches a user-authored one)')
.option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)') .option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)')
.option('-c, --case <name>', 'Remove from <case>/.claude/skills/codeman instead (linked cases resolve too)') .option(
'-c, --case <name>',
"Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)"
)
.action(async (options: { global?: boolean; case?: string }) => { .action(async (options: { global?: boolean; case?: string }) => {
try { try {
const target = resolveSkillTarget(options); if (!options.case) {
reportSkillResult(await removeAgentSkillFrom(target), target); const target = resolveSkillTarget(options);
reportSkillResult(await removeAgentSkillFrom(target), target);
return;
}
let failed = false;
let removedAny = false;
for (const skillDir of registryAgentSkillDirs()) {
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}`));
else {
console.error(palette.err(`✗ ${message.text}`));
failed = true;
}
}
if (!removedAny) console.log(palette.ok(`✓ No Codeman-managed agent skill in case ${options.case}`));
if (failed) process.exit(1);
} catch (err) { } catch (err) {
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`)); console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
process.exit(1); process.exit(1);
@@ -234,6 +325,25 @@ skillCmd
registerAgentCommands(program); 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 ============ // ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); 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 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 // File Download Limits
// ============================================================================ // ============================================================================
+14 -1
View File
@@ -15,7 +15,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js'; import type { AgentSkillDir, LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
/** `capabilities.agentSkillDir`: every value must stay a fixed, relative, `..`-free path. */
export const AGENT_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const satisfies readonly AgentSkillDir[];
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z const cliId = z
@@ -294,6 +297,10 @@ const capabilitiesSchema = z
wheelForward: z wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(), .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']), keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(), privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']), startMode: z.enum(['interactive', 'shell']),
@@ -302,6 +309,9 @@ const capabilitiesSchema = z
respawn: z.boolean(), respawn: z.boolean(),
effort: z.boolean(), effort: z.boolean(),
agentSkillInjection: z.boolean(), agentSkillInjection: z.boolean(),
// A closed list on purpose: it decides where the server WRITES, and this file is
// user-editable, so it can never be a free path (`..`, absolute).
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
statusLineTelemetry: z.boolean(), statusLineTelemetry: z.boolean(),
// How many columns this CLI indents its transcript body by, so a copy can take // How many columns this CLI indents its transcript body by, so a copy can take
// that much off the clipboard. Bounded, because it is the whole strip: a copy // that much off the clipboard. Bounded, because it is the whole strip: a copy
@@ -443,6 +453,9 @@ const capabilitiesSchema = z
.object({ .object({
// Home-relative, no traversal: sync writes to this path. // Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath, 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 // 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. // keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([ format: z.enum([
+43
View File
@@ -76,6 +76,21 @@ function agentDefaults(): Pick<
}; };
} }
/**
* The agent skill in `<workspace>/.agents/skills/codeman`, the one project skills
* directory every CLI that spreads this scans. Measured per CLI on
* 2026-10-11 against the installed builds or their source: codex 0.162 (every dir from
* the git root down to cwd), gemini (cwd only, behind folder trust, which `--skip-trust`
* already answers), opencode 1.3 (cwd up to the worktree), agy 1.3.2 (its `.agents/`
* customization root), grok 1.0.5 (each tier from cwd up to the repo root), dsh 0.1.1
* (the git root, else cwd) and omp (cwd up to the git root). Claude Code does NOT read
* `.agents/skills`, so a case shared with a claude session lists each skill once there.
*/
const AGENTS_DIR_SKILL: Pick<CliEntry['capabilities'], 'agentSkillInjection' | 'agentSkillDir'> = {
agentSkillInjection: true,
agentSkillDir: '.agents/skills',
};
// `accent` on every entry below (except SHELL, which the frontend renders no // `accent` on every entry below (except SHELL, which the frontend renders no
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>` // distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest // CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
@@ -270,6 +285,10 @@ const CLAUDE: CliEntry = {
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version // 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. // AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, 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', keyboardAccessory: 'agent',
privilegedCommandGate: false, privilegedCommandGate: false,
startMode: 'interactive', startMode: 'interactive',
@@ -278,6 +297,7 @@ const CLAUDE: CliEntry = {
respawn: true, respawn: true,
effort: true, effort: true,
agentSkillInjection: true, agentSkillInjection: true,
agentSkillDir: '.claude/skills',
statusLineTelemetry: true, statusLineTelemetry: true,
model: { source: 'claude-settings-file' }, model: { source: 'claude-settings-file' },
privilegedParams: [], privilegedParams: [],
@@ -494,8 +514,14 @@ const OPENCODE: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse', altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, 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 // 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 // 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 // composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
@@ -536,6 +562,7 @@ const OPENCODE: CliEntry = {
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`. // opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: { mcpConfig: {
path: '.config/opencode/opencode.json', path: '.config/opencode/opencode.json',
altPath: '.config/opencode/opencode.jsonc',
format: 'opencode-json', format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' }, relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
}, },
@@ -627,6 +654,7 @@ const CODEX: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
// Codex draws `› Ask Codex to do anything` on its composer row and // Codex draws `› Ask Codex to do anything` on its composer row and
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no // `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase // braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
@@ -706,6 +734,11 @@ const CODEX: CliEntry = {
altScreen: 'strip-full', altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward 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, maxFrameBytes: 32 * 1024,
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so // 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. // the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
@@ -816,6 +849,7 @@ const GEMINI: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-full', altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real // Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
@@ -924,6 +958,7 @@ const ANTIGRAVITY: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-only', altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
@@ -1019,6 +1054,11 @@ const PI: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
// No agent skill (agentSkillInjection stays false): any project-level `.agents/skills`
// or `.pi/skills` makes pi ask "Trust project folder?" at startup
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn, // Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
@@ -1169,6 +1209,7 @@ const GROK: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured // Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full // (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
// erase-scrollback+DECSET strip. // erase-scrollback+DECSET strip.
@@ -1341,6 +1382,7 @@ const DEEPSEEK: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
// Definitive rather than inferred: the harness TUI reports idle/working/blocked to a // Definitive rather than inferred: the harness TUI reports idle/working/blocked to a
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because // supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
// the session can disarm the bridge, and docker/remote cannot reach it at all. // the session can disarm the bridge, and docker/remote cannot reach it at all.
@@ -1506,6 +1548,7 @@ const OMP: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the // Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. // tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
altScreen: 'strip-mux-only', altScreen: 'strip-mux-only',
+41 -1
View File
@@ -69,6 +69,14 @@ export type Cond =
*/ */
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single'; export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
/**
* Workspace-relative directories an agent CLI scans for project skills. A closed list
* rather than a free path: the value decides where the server WRITES, and
* `~/.codeman/clis.json` can set it, so it must never be able to name `..` or an
* absolute path.
*/
export type AgentSkillDir = '.claude/skills' | '.agents/skills';
/** One argv element. */ /** One argv element. */
export type ArgSpec = export type ArgSpec =
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */ /** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
@@ -507,6 +515,20 @@ export interface CliCapabilities {
}; };
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; 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'; keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean; privilegedCommandGate: boolean;
@@ -515,7 +537,20 @@ export interface CliCapabilities {
ralph: boolean; ralph: boolean;
respawn: boolean; respawn: boolean;
effort: boolean; effort: boolean;
/**
* Codeman may write its agent skill (`skills/codeman`) into this CLI's workspace and
* seed its per-session preamble (behind the synced `agentSkillEnabled` setting,
* add-only, marker-owned). Where it lands comes from `agentSkillDir` below.
*/
agentSkillInjection: boolean; agentSkillInjection: boolean;
/**
* The workspace-relative directory this CLI scans for project skills, so the skill
* lands at `<workspace>/<agentSkillDir>/codeman/SKILL.md` where the CLI discovers it.
* Measured per CLI. Absent with `agentSkillInjection: true` means Claude Code's
* `.claude/skills`, which is what the boolean meant before this field existed, so a
* `~/.codeman/clis.json` written against the old schema keeps its behaviour.
*/
agentSkillDir?: AgentSkillDir;
statusLineTelemetry: boolean; statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */ /** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
@@ -644,7 +679,12 @@ export interface CliCapabilities {
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file * reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`. * 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 * 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 * 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 };
}
+54 -20
View File
@@ -36,6 +36,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js'; import type { HookEventType } from './types.js';
import type { AgentSkillDir, CliCapabilities } from './config/cli-registry/types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js'; import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
@@ -1228,8 +1229,8 @@ interface AgentSkillFile {
*/ */
async function readAgentSkillSource(): Promise<AgentSkillFile[]> { async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
const src = agentSkillSourceDir(); const src = agentSkillSourceDir();
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8'); const skillMd = await readFile(join(src, 'SKILL.md'), 'utf-8');
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }]; const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skillMd.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
let referenceNames: string[] = []; let referenceNames: string[] = [];
try { try {
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort(); referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
@@ -1285,7 +1286,8 @@ export type AgentSkillApplyResult =
/** /**
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman` * Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
* directory, e.g. `<case>/.claude/skills/codeman` or `~/.claude/skills/codeman`). * directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
* `<case>/.agents/skills/codeman`).
* *
* Refuses two shapes rather than writing through them: * Refuses two shapes rather than writing through them:
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding * - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
@@ -1336,16 +1338,20 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
} }
/** /**
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`, * Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
* default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent * default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent
* skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the * skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the
* model has to type out (measured live: that paste alone cost a spawn run ~47 s of * model has to type out (measured live: that paste alone cost a spawn run ~47 s of
* generation time). The path formula must match the skill's * generation time). The path formula must match the skill's
* `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so * `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so
* reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the * reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the
* shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a * shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets
* remote or in-container HOME is not this filesystem) and treat it as best-effort: the * the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
* skill's §0 fallback block self-heals a missing or stale file. * claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
* file and its self id is wrong; the skill tells the agent how to notice.
*/ */
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> { export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8'); const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
@@ -1478,31 +1484,59 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
} }
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
await rmdir(skillDir).catch(() => {}); await rmdir(skillDir).catch(() => {});
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
return 'removed'; return 'removed';
}); });
} }
/** /**
* Add or remove the Codeman agent skill in `<case>/.claude/skills/codeman`, * Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
* mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled` * had before `capabilities.agentSkillDir` existed.
* app setting (default OFF); callers gate on Claude mode, since the skill is discovered */
* via `.claude/skills/`, which only Claude Code reads. export const CLAUDE_AGENT_SKILL_DIR: AgentSkillDir = '.claude/skills';
/**
* The workspace-relative skills directory this CLI scans, i.e. where its sessions get
* the skill, or `null` when they get none. Pure registry data, so no call site
* branches on a CLI id.
*/
export function agentSkillDir(
capabilities: Pick<CliCapabilities, 'agentSkillInjection' | 'agentSkillDir'> | undefined
): AgentSkillDir | null {
if (!capabilities?.agentSkillInjection) return null;
return capabilities.agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR;
}
/** `<casePath>/<dir>/codeman`, the directory the skill's SKILL.md lives in. */
export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
return join(casePath, dir, 'codeman');
}
/**
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
* *
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or * Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
* skip the call), for the statusLine reason: sessions in a repo share one `.claude/` * skip the call), for the statusLine reason: sessions in a repo share one workspace,
* dir, so a single create while the setting is off must not yank the skill out from * so a single create while the setting is off must not yank the skill out from
* under other live sessions. * under other live sessions.
* *
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately * ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
* no server-side toggle-off sweep (it would have to walk every case, including ones * no server-side toggle-off sweep (it would have to walk every case, including ones
* with live sessions, and would hit exactly the shared-`.claude/` hazard above), so * with live sessions, and would hit exactly the shared-workspace hazard above), so
* already-injected copies stay on disk until removed per case with * already-injected copies stay on disk until removed per case with
* `codeman skill uninstall --case <name>`. The `enabled: false` branch here backs that * `codeman skill uninstall --case <name>`, which removes the Codeman-managed copy from
* CLI and the tests; it has no server call site. Keep the README's Agent Skill note in * every skills directory the registry could have written. The `enabled: false` branch
* sync if this ever changes. * here backs that CLI and the tests; it has no server call site. Keep the README's
* Agent Skill note in sync if this ever changes.
*/ */
export async function applyAgentSkill(casePath: string, enabled: boolean): Promise<AgentSkillApplyResult> { export async function applyAgentSkill(
const skillDir = join(casePath, '.claude', 'skills', 'codeman'); casePath: string,
enabled: boolean,
dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
): Promise<AgentSkillApplyResult> {
const skillDir = agentSkillDirFor(casePath, dir);
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir); return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
} }
+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 { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os'; 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 { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js'; import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js'; import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
@@ -66,6 +66,11 @@ export interface McpSyncTarget {
label: string; label: string;
/** Home-relative default location of the config file. */ /** Home-relative default location of the config file. */
path: string; 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; format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */ /** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string }; relocation?: { envVar: string; path: string };
@@ -197,11 +202,12 @@ function toClaude(s: McpServer): Record<string, unknown> {
function fromGemini(raw: unknown): McpServer | null { function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null; if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes // `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') if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) }); return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') { 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') { if (typeof raw.command === 'string') {
return clean({ return clean({
@@ -255,7 +261,7 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
function fromOpencode(raw: unknown): McpServer | null { function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return 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') { if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled }); 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 { interface JsonDialect {
/** Key holding the server table. */ /** Key holding the server table. */
key: string; key: string;
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
nestedKey?: string;
from(raw: unknown): McpServer | null; from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null; to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */ /** 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': { 'opencode-json': {
key: 'mcp', key: 'mcp',
nestedKey: 'servers',
from: fromOpencode, from: fromOpencode,
to: toOpencode, to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' }, 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]; const table = doc[dialect.key];
if (table === undefined) return dict<unknown>(); if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`); 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. */ /** 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) }; 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 * 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 * 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) { for (const s of state) {
if (s.res.status !== 'ok') continue; if (s.res.status !== 'ok') continue;
try { 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))) { if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent'; s.res.status = 'absent';
continue; continue;
+36
View File
@@ -25,6 +25,7 @@ import type {
SessionRemote, SessionRemote,
SessionDocker, SessionDocker,
PaneExit, PaneExit,
StatusLineExporter,
} from './types.js'; } from './types.js';
/** /**
@@ -57,6 +58,8 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig; respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */ /** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean; 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 * 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 * bookkeeping, so everything on it but the name and the pid is a guess. Its
@@ -189,6 +192,21 @@ export interface PaneCaptureOptions {
fullHistory?: boolean; fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */ /** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number; 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 * 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 * 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. * rendering it needs the real height to know the frame fits.
*/ */
capturedGeometry?: { cols: number; rows: number }; 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 * `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. * 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 * Symlink resolution is portable AND fails closed. `readlink -f` where available
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain * (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` * 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() {', 'probe() {',
' i=$1', ' i=$1',
' p=$2', ' 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`, ` 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; }`, ` 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; }`, ` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
@@ -269,6 +275,126 @@ export async function remoteProbePaths(
return results; 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`. */ /** Read a whole remote file into memory, capped by `maxBytes`. */
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> { export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
assertNotUnderTest(); 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 * @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 * 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: * 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; 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) { if (active < MAX_CONCURRENT_REMOTE_SSH) {
active++; active++;
return Promise.resolve(); 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 { 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> { * Run `task` once an ssh slot is free, releasing the slot afterward. With
await acquire(); * `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 { try {
return await task(); return await task();
} finally { } finally {
+57
View File
@@ -63,6 +63,7 @@ import {
type SessionNameSource, type SessionNameSource,
type SessionWriteOptions, type SessionWriteOptions,
type PaneExit, type PaneExit,
type PromptCacheStatus,
} from './types.js'; } from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.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. * running. A session that comes back with no label has no chip on its screen.
*/ */
private _watching: string | null = null; 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(). */ /** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined; private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */ /** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
@@ -873,6 +876,8 @@ export class Session extends EventEmitter {
paneExit?: PaneExit; paneExit?: PaneExit;
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */ /** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
displayModel?: 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. */ /** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean; discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */ /** 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`. // browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit); this.setPaneExit(config.paneExit);
this._reportedModel = restoredReportedModel(config.displayModel) ?? null; 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 // 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 // lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk. // both the id and the saved parent come from disk.
@@ -1244,6 +1254,11 @@ export class Session extends EventEmitter {
} }
if (newId === this._claudeSessionId) return; if (newId === this._claudeSessionId) return;
this._claudeSessionId = newId; 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; 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. * 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. * 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, autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined, autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineExporter: this._muxSession?.statusLine,
imageWatcherEnabled: this._imageWatcherEnabled, imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined, pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
+68 -12
View File
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
const isRanked = (ref: TabRef): boolean => const isRanked = (ref: TabRef): boolean =>
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id); 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 rankContainer = (refs: readonly TabRef[]): TabRef[] => {
const ranked = refs const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
.filter(isRanked)
.map(prepare)
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
let rankedIndex = 0; 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, ...layout,
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })), groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
ungrouped: rankContainer(layout.ungrouped), 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); 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( export function recomposeGlobalSessionOrder(
current: readonly string[], current: readonly string[],
projections: readonly OwnerOrderProjection[], projections: readonly OwnerOrderProjection[],
+127 -6
View File
@@ -11,7 +11,9 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
import { import {
flattenOwnerSessionOrder, flattenOwnerSessionOrder,
materializeOrphans, materializeOrphans,
moveRef,
normalizeTabLayout, normalizeTabLayout,
setManualPlacement,
TabLayoutValidationError, TabLayoutValidationError,
validateTabLayout, validateTabLayout,
type TabLayout, type TabLayout,
@@ -42,6 +44,12 @@ interface TabLayoutServiceDeps {
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>; readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
broadcast(event: string, data: unknown): void; broadcast(event: string, data: unknown): void;
broadcastSessionOrder(change: SessionOrderProjectionChange): 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; now?: () => string;
} }
@@ -67,6 +75,18 @@ export interface RemovedTabLayoutSession {
owner?: string; 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 { interface PreparedOwnerLayout {
current: TabLayout | null; current: TabLayout | null;
authoritative: TabLayout; 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 => const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && a.every((id, index) => id === b[index]); 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 { export class TabLayoutService {
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending'; private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
private readonly ownerQueues = new Map<string, Promise<void>>(); 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); 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 = { const publication = {
owner, owner,
previous: prepared.current, previous: prepared.current,
@@ -335,7 +419,7 @@ export class TabLayoutService {
return prepared.authoritative; return prepared.authoritative;
} }
const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; 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> { 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> { * Reconcile one completed session creation into one versioned mutation.
return this.get(owner); *
* `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. */ /** Reconcile one completed saved-webview creation into one versioned mutation. */
+21 -2
View File
@@ -15,6 +15,13 @@ export interface TabRef {
kind: TabRefKind; kind: TabRefKind;
id: string; id: string;
placement?: 'manual'; 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 { export interface TabGroup {
@@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef {
if (value.placement !== undefined && value.placement !== 'manual') { if (value.placement !== undefined && value.placement !== 'manual') {
throw new TabLayoutValidationError(`${label}.placement must be manual when present`); 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 { 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' })); 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 { export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
const normalized = normalizeTabLayout(input, metadata); const normalized = normalizeTabLayout(input, metadata);
if (target.kind !== 'session') { 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}`); 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); return normalizeTabLayout(cleared, metadata);
} }
+189 -20
View File
@@ -59,6 +59,7 @@ import {
type SessionDocker, type SessionDocker,
type DockerCommandMode, type DockerCommandMode,
type PaneExit, type PaneExit,
type StatusLineExporter,
} from './types.js'; } from './types.js';
import { getCli } from './config/cli-registry/registry.js'; import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.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'); 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. */ /** Pane geometry and caret position, as `display-message` reports them. */
interface PaneCursorGeometry { interface PaneCursorGeometry {
cols: number; cols: number;
rows: number; rows: number;
cursorX: number; cursorX: number;
cursorY: 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. * 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 * The four geometry fields are validated together: a caller that gets a value
* a caret with it, and one that gets null must not try. * 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 { export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
let raw: string; 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); console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null; 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 ( if (
!Number.isFinite(cursorX) || !Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) || !Number.isFinite(cursorY) ||
@@ -729,7 +839,10 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
) { ) {
return 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. */ /** 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)}`; 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 * 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 * 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 * Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared * 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(' && '); const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// Registry-gated (capabilities.statusLineTelemetry — claude only today), local const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
// spawns only (remote/docker have their own separate command builders — out of mode,
// scope here). Also self-heals: strips any legacy disk-written exporter from an workingDir,
// older Codeman build the first time a session starts in that workspace again. !!(remote || docker)
const statusLineCommand = );
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
// The user's own REAL statusLine, if any (walked via Claude Code's own // The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script // settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting. // can wrap it. Only worth discovering when we're actually injecting.
@@ -2327,6 +2471,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode, mode,
attached: false, attached: false,
name, name,
statusLine,
}; };
this.sessions.set(sessionId, session); this.sessions.set(sessionId, session);
@@ -2451,11 +2596,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// See createSession()'s identical resolution for rationale. const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
const statusLineCommand = mode,
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker workingDir,
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled()) !!(remote || docker)
: undefined; );
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
const baseCmd = buildSpawnCommand({ const baseCmd = buildSpawnCommand({
@@ -2518,6 +2663,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.clearPaneExit(muxName); this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName); const pid = this.getPanePid(muxName);
if (pid) session.pid = pid; if (pid) session.pid = pid;
if (session.statusLine !== statusLine) {
session.statusLine = statusLine;
this.saveSessions();
}
return pid; return pid;
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to respawn pane:', 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 typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
? Math.trunc(requestedLines) ? Math.trunc(requestedLines)
: DEFAULT_TMUX_HISTORY_LIMIT; : 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 // execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
// (ENOBUFS) and would silently degrade full-history capture to the byte // (ENOBUFS) and would silently degrade full-history capture to the byte
// buffer for exactly the long sessions it exists for — size it from the // 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 = execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES; (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 // 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 // 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 // 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. // to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() => const geometry = queryPaneCursor(() =>
execSync( 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 } { 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 // geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one. // the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }; 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) { if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim. // 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; 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 { export interface SessionState {
/** Unique session identifier */ /** Unique session identifier */
id: string; id: string;
@@ -775,6 +820,16 @@ export interface SessionState {
autoResumeEnabled?: boolean; autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */ /** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number; 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 to the top of the session manager list (COD-139) */
pinned?: boolean; pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ /** 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 * 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. * 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 * The Codex parser consumes the read-only `account/rateLimits/read` app-server
* response and selects only the main `codex` bucket, excluding model-specific * response and selects only the main `codex` bucket, excluding model-specific
* buckets. All functions are pure for testability. See * buckets. All functions are pure for testability. See
@@ -23,6 +30,10 @@
* @module usage-telemetry * @module usage-telemetry
*/ */
import type { PromptCacheStatus } from './types/index.js';
export type { PromptCacheStatus };
/** A single normalized plan-usage window. */ /** A single normalized plan-usage window. */
export interface UsageWindow { export interface UsageWindow {
/** Percent of the window consumed, 0–100. */ /** 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 }; context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
cost?: { total_cost_usd?: number }; cost?: { total_cost_usd?: number };
model?: { display_name?: string }; 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 { interface RawCodexRateLimitWindow {
@@ -107,6 +136,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
return t; 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. */ /** Normalize the main Codex app-server bucket into the chip's two known windows. */
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null { export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
if (!value || typeof value !== 'object') return null; if (!value || typeof value !== 'object') return null;
@@ -140,6 +206,8 @@ export interface SessionStatus {
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
contextUsedPercentage?: 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". */ /** 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') { if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage); s.contextUsedPercentage = clampPct(cw.used_percentage);
} }
const cache = parsePromptCache(data);
if (cache) s.cache = cache;
return Object.keys(s).length ? s : null; return Object.keys(s).length ? s : null;
} }
/** /**
* Format the in-terminal statusline footer: the CURRENT SESSION's status — * `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits, * once Claude reports it cold or the render happened past `expires_at`. A clock
* which live in the Codeman header chip. Claude requires a statusLine command to * time rather than a countdown because the footer only re-renders on statusline
* emit the rate_limits JSON at all, so this is what that command prints back * triggers, so a countdown would sit stale on screen between turns.
* 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.
*/ */
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 ''; if (!s) return '';
const groups: string[] = []; const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName); 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 (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' ')); if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`); 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(' ') : ''; return groups.length ? groups.join(' ') : '';
} }
+91
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -85,6 +85,13 @@ export interface ApprovalItem {
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed. * answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
*/ */
options?: ApprovalOption[]; 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 { export interface ApprovalResolvedInfo {
@@ -108,6 +115,11 @@ interface NotePromptArgs {
* opens ALREADY acknowledged: see `notePrompt()`. * opens ALREADY acknowledged: see `notePrompt()`.
*/ */
watching?: string | null; 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. */ /** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null; capture?: () => string | null;
} }
@@ -261,6 +273,7 @@ export class ApprovalInbox {
toolSummary: args.toolSummary, toolSummary: args.toolSummary,
message: args.message, message: args.message,
cwd: args.cwd, cwd: args.cwd,
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
}; };
if (args.kind === 'idle' && args.watching) { if (args.kind === 'idle' && args.watching) {
item.acknowledgedAt = item.createdAt; 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 { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js'; import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js'; import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { import {
capabilityFromProxyPath, capabilityFromProxyPath,
capabilityFromReferer, capabilityFromReferer,
@@ -183,6 +184,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined; 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 * A web-tab frame that navigated itself off the proxy prefix (see
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie, * isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
@@ -368,6 +383,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done(); done();
return; 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. // A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return; if (serveLostWebviewFrame(req, reply)) return;
@@ -507,6 +527,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter // ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials. // than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return; 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. // A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return; 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 // are not CORS-checked). Falling through lets the proxy route reply with the
// right headers. // right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) { 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(); reply.code(204).send();
done(); done();
return; return;
+8 -1
View File
@@ -5,10 +5,17 @@
import type { Session } from '../../session.js'; import type { Session } from '../../session.js';
import type { SessionState } from '../../types.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 { export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>; 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>; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>; setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void; persistSessionState(session: Session): void;
+463 -35
View File
@@ -193,6 +193,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'], [SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'], [SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'], [SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
// Scheduled runs // Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
@@ -703,6 +704,9 @@ const URL_SESSION_WAIT_MS = 30000;
*/ */
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000; 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 { class CodemanApp {
constructor() { constructor() {
this.sessions = new Map(); this.sessions = new Map();
@@ -768,6 +772,12 @@ class CodemanApp {
// their scrollback can be very large; full history stays available on demand. // 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). // Tracked PER SESSION rather than as a single "first load" flag (issue #205).
this._fullHistoryLoaded = new Set(); 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. // Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp> this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false; this._fullHistoryRepullInFlight = false;
@@ -2367,10 +2377,13 @@ class CodemanApp {
// A session this tab is closing stays closed until the server answers. // A session this tab is closing stays closed until the server answers.
if (this._closingSessions?.has(data.id)) return; if (this._closingSessions?.has(data.id)) return;
this.sessions.set(data.id, data); this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) { if (!this.sessionOrder.includes(data.id)) {
this.sessionOrder.push(data.id); // The server placed it when it recorded the creation (a child after its
this.saveSessionOrder(); // 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 // Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order. // land here, and a batch launched together cascades in creation order.
@@ -2811,7 +2824,13 @@ class CodemanApp {
const filePath = pathLink.dataset.path; const filePath = pathLink.dataset.path;
// A rendered document's links name the session the preview was opened // A rendered document's links name the session the preview was opened
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab. // 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; return;
} }
@@ -2929,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 * 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 * markdown, and a path the agent wrote as prose or inline code renders as
@@ -2947,8 +2967,7 @@ class CodemanApp {
if (!root || typeof document === 'undefined') return; if (!root || typeof document === 'undefined') return;
// Guarded: a stale cached constants.js must degrade to plain text, not throw // Guarded: a stale cached constants.js must degrade to plain text, not throw
// out of the middle of rendering a message. // out of the middle of rendering a message.
if (typeof absoluteFilePathPattern !== 'function') return; if (typeof findFilePathLinks !== 'function') return;
const pattern = absoluteFilePathPattern();
// Collect first: replacing a node while the walker is positioned on it // Collect first: replacing a node while the walker is positioned on it
// invalidates the traversal. // invalidates the traversal.
@@ -2956,27 +2975,26 @@ class CodemanApp {
const targets = []; const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) { for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue; if (node.parentElement?.closest('a')) continue;
pattern.lastIndex = 0; const found = findFilePathLinks(node.nodeValue || '');
if (pattern.test(node.nodeValue || '')) targets.push(node); if (found.length > 0) targets.push([node, found]);
} }
for (const node of targets) { for (const [node, found] of targets) {
const value = node.nodeValue; const value = node.nodeValue;
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
let cursor = 0; let cursor = 0;
let match; for (const { path, index, folder } of found) {
pattern.lastIndex = 0; if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
while ((match = pattern.exec(value)) !== null) {
const path = match[1];
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
const link = document.createElement('a'); const link = document.createElement('a');
link.className = 'rv-path'; link.className = 'rv-path';
link.href = '#'; link.href = '#';
link.dataset.path = path; link.dataset.path = path;
link.dataset.linkified = '1';
if (folder) link.dataset.folder = '1';
link.title = path; link.title = path;
link.textContent = path; link.textContent = path;
frag.appendChild(link); frag.appendChild(link);
cursor = match.index + path.length; cursor = index + path.length;
} }
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor))); if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
node.parentNode?.replaceChild(frag, node); node.parentNode?.replaceChild(frag, node);
@@ -3270,15 +3288,16 @@ class CodemanApp {
this._terminalRefreshOwner = refreshOwner; this._terminalRefreshOwner = refreshOwner;
try { try {
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic // A shell can retain a multi-megabyte/100k-line tmux history. Automatic
// recovery stays bounded just like normal shell selection; only the // recovery stays bounded just like normal shell selection (tmux's rendered
// explicit "Load full history" action is allowed to pay for a full replay. // 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 // 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. // repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell'; const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
let capture = await this._fetchTerminalCapture( let capture = await this._fetchTerminalCapture(
useFullHistory useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1` ? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`, : `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`,
{ full: useFullHistory } { full: useFullHistory }
); );
let headersReceivedAt = capture.headersAt; let headersReceivedAt = capture.headersAt;
@@ -3288,6 +3307,7 @@ class CodemanApp {
headersReceivedAt = capture.headersAt; headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {}; data = capture.json?.data ?? {};
} }
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's // 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 // 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. // fetches wide in the fallback case, so this guard is not optional.
@@ -3375,8 +3395,13 @@ class CodemanApp {
try { try {
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling, // No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
// so it needs the full-history budget. Defaulting to the tail budget // so it needs the full-history budget. Defaulting to the tail budget
// gave the largest payload the smallest deadline. // gave the largest payload the smallest deadline. A shell reloads tmux's
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true }); // 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 headersReceivedAt = capture.headersAt;
const termData = capture.json?.data ?? {}; const termData = capture.json?.data ?? {};
@@ -6225,6 +6250,8 @@ class CodemanApp {
} }
} }
this._syncTabTreeSelection(container); 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 // #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, // strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen. // Alt+N or a push notification could stay parked off-screen.
@@ -6716,6 +6743,10 @@ class CodemanApp {
this._refreshMobileOverviewIfVisible?.(); this._refreshMobileOverviewIfVisible?.();
// Same deal for the desktop home screen's tab column. // Same deal for the desktop home screen's tab column.
this._refreshHomeSessionsIfVisible?.(); 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 // 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 // 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 // it, sliding the lineage lines off their anchors. Only pay for it when there
@@ -7042,6 +7073,7 @@ class CodemanApp {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside }); this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection); this._syncTabGroupHeaderAlerts(container, groupProjection);
} }
this._noteTabGroupArrivals(container, groupProjection);
this._syncTabTriageChrome(container, triage); this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection }); this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container); const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
@@ -7068,6 +7100,10 @@ class CodemanApp {
// Set up keyboard navigation for tabs // Set up keyboard navigation for tabs
this.setupTabKeyboardNavigation(container); 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) // Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines(); this.updateConnectionLines();
@@ -7316,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. */ /** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) { _syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return; if (container?.getAttribute('role') !== 'tree') return;
@@ -7602,6 +7670,9 @@ class CodemanApp {
// rail (always so on the flat rail, which is every owner without groups). // rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed. // Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs(); 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). // Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
if (next && !this._tabLayoutRestoreChecked) { if (next && !this._tabLayoutRestoreChecked) {
this._tabLayoutRestoreChecked = true; this._tabLayoutRestoreChecked = true;
@@ -7638,6 +7709,8 @@ class CodemanApp {
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds], collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId, activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId, activeWebviewId: this.activeWebviewId,
// A session spawned by another one shows up in its parent's group at once.
parents: this._tabLayoutParents(),
}); });
} }
@@ -7675,6 +7748,226 @@ class CodemanApp {
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse; 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 // Owner tab layout: editing groups from the vertical rail
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -7838,8 +8131,8 @@ class CodemanApp {
if (!this._tabLayoutEditable()) return []; if (!this._tabLayoutEditable()) return [];
const location = this._tabRefLocation(ref); const location = this._tabRefLocation(ref);
if (!location) return []; if (!location) return [];
const actions = [];
const grouped = this.tabLayout.groups.length > 0; 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 // 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). // sort is on (then the sort decides and there is nothing to reorder).
if (grouped && !this.isTabRailSorted()) { if (grouped && !this.isTabRailSorted()) {
@@ -7864,6 +8157,55 @@ class CodemanApp {
return actions; 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) ── // ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/** /**
@@ -7886,10 +8228,11 @@ class CodemanApp {
menu.remove(); menu.remove();
if (!restoreFocus) return; if (!restoreFocus) return;
// The trigger may have been re-rendered while the menu was open; find the // 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 rail = this.$('sessionTabs');
const item = 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); [...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
item?.focus(); item?.focus();
} }
@@ -7904,6 +8247,7 @@ class CodemanApp {
const index = groups.findIndex((group) => group.id === groupId); const index = groups.findIndex((group) => group.id === groupId);
if (index < 0) return false; if (index < 0) return false;
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [ 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) }] : []), ...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) }, { label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []), ...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
@@ -7915,7 +8259,8 @@ class CodemanApp {
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */ /** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
openTabWebviewMenu(event, webviewId) { 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) { if (!moves.length) {
this.showWebviewModal?.(webviewId); this.showWebviewModal?.(webviewId);
return false; return false;
@@ -8807,6 +9152,7 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt; const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {}; const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now(); const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer; const buffer = payload.terminalBuffer;
const timing = { const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll', trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -8941,9 +9287,14 @@ class CodemanApp {
source: payload.source ?? null, source: payload.source ?? null,
fullSize: payload.fullSize ?? 0, fullSize: payload.fullSize ?? 0,
retainedBytes: payload.retainedBytes ?? 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 // 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. // browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted, 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(); if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
} }
@@ -8951,9 +9302,45 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */ /** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) { _clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId); this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
this._paneHistoryLines?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner(); 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. * Paint the partial-history banner for the active session.
* *
@@ -8963,13 +9350,22 @@ class CodemanApp {
* - recoverable → offer to load the rest * - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing * - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling * - 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() { _renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar'); const bar = document.getElementById('historyTruncationBar');
if (!bar) return; 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 || {}); const notice = computeHistoryTruncationNotice(state || {});
if (!notice.visible) { if (
!notice.visible ||
this._historyNoticeRevealedFor !== sessionId ||
this._historyNoticeDismissed?.has(sessionId)
) {
bar.hidden = true; bar.hidden = true;
return; return;
} }
@@ -9002,6 +9398,9 @@ class CodemanApp {
dismiss.setAttribute('aria-label', 'Dismiss history notice'); dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×'; dismiss.textContent = '×';
dismiss.onclick = () => { 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.hidden = true;
}; };
bar.appendChild(dismiss); bar.appendChild(dismiss);
@@ -9115,7 +9514,9 @@ class CodemanApp {
this._activateFileBrowserSession?.(sessionId); this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The // Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous // 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(); this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {} try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming // Narrow SSE filter to the active session — server stops streaming
@@ -9384,16 +9785,41 @@ class CodemanApp {
// TUI sessions still get one canonical full replay per page (COD-47/#205). // 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 // A shell can retain hundreds of thousands of plain scrollback lines, so
// automatically replaying all of them makes tab selection scale with the // automatically replaying all of them makes tab selection scale with the
// entire session. Load its bounded 1MB tail first; the existing truncation // entire session. Load a bounded window of tmux's RENDERED history first
// banner action fetches ?full=1 when the user explicitly asks for it. // (SHELL_LOAD_QUERY: never the raw byte recording, whose repaints replay
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId); // 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); if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now(); const fetchStartedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`; const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
let capture; let capture;
try { try {
capture = await this._fetchTerminalCapture( 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 } { full: useFullHistory }
); );
} catch (err) { } catch (err) {
@@ -9416,6 +9842,7 @@ class CodemanApp {
} }
const data = capture.json?.data ?? {}; const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now(); const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is // 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 // 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 // point-in-time frame, so nothing that reached the browser after the
@@ -9716,8 +10143,9 @@ class CodemanApp {
// //
// UNREACHABLE as written, and kept for the invariant rather than the // UNREACHABLE as written, and kept for the invariant rather than the
// branch. A `useFullHistory` pass sends `full=1`, and the route answers // branch. A `useFullHistory` pass sends `full=1`, and the route answers
// `full=1` with `mux-full-history` or `history`, never `mux-visible` // a `full=1` pass without `alt=recording` (every `useFullHistory` pass;
// (see the source ladder in session-routes.ts), so the gate above // 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 // 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 // line as evidence that a page load retries: it does not, and the test
// suite pins that it does not. // suite pins that it does not.
@@ -9860,8 +10288,8 @@ class CodemanApp {
// Remove from tab order // Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId); const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) { if (orderIndex !== -1) {
// Not saved: the server's deletion already re-projected and broadcast the order.
this.sessionOrder.splice(orderIndex, 1); this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
} }
this.terminalBuffers.delete(sessionId); this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.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}')">` + `onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` + `<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
`</div>`; `</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) { } else if (item.options && item.options.length) {
actions = item.options actions = item.options
.map( .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 THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading 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) 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 SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling 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 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? // SSE staleness policy: is this stream a zombie?
// //
// An EventSource that stops delivering does not always error. A proxy that // An EventSource that stops delivering does not always error. A proxy that
@@ -1447,6 +1501,9 @@ if (typeof window !== 'undefined') {
compute: computeSseStale, compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS, TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
}; };
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
};
window.CodemanSessionOrder = { window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK, RANK: SESSION_ACTIVITY_RANK,
anchor: sessionActivityAnchor, anchor: sessionActivityAnchor,
@@ -1507,6 +1564,15 @@ function scheduleBackground(fn) {
// but server-sent terminal buffers may still contain markers from Claude CLI. // but server-sent terminal buffers may still contain markers from Claude CLI.
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g; 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 // Built-in respawn configuration presets
const BUILTIN_RESPAWN_PRESETS = [ const BUILTIN_RESPAWN_PRESETS = [
{ {
@@ -1632,6 +1698,7 @@ const SSE_EVENTS = {
SESSION_INTERACTIVE: 'session:interactive', SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running', SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry', SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
SESSION_PROMPT_CACHE: 'session:promptCache',
// Scheduled runs // Scheduled runs
SCHEDULED_CREATED: 'scheduled:created', SCHEDULED_CREATED: 'scheduled:created',
@@ -1860,7 +1927,10 @@ function escapeHtml(text) {
function formatHistoryBytes(bytes) { function formatHistoryBytes(bytes) {
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0; const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
if (n < 1024) return 'less than 1 KB'; 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`; return `${(n / (1024 * 1024)).toFixed(1)} MB`;
} }
@@ -1874,12 +1944,27 @@ function formatHistoryBytes(bytes) {
* - atCeiling: the FULL capture itself hit the byte ceiling * - atCeiling: the FULL capture itself hit the byte ceiling
* - exhausted: a full pull was refused as a downgrade, so this is all there is * - 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, * @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}} * @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/ */
function computeHistoryTruncationNotice(state = {}) { function computeHistoryTruncationNotice(state = {}) {
if (!state.truncated) return { visible: false, message: '', canLoadMore: false }; 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 retained = Math.max(0, state.retainedBytes || 0);
const dropped = Math.max(0, (state.fullSize || 0) - retained); const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1902,9 +1987,22 @@ function computeHistoryTruncationNotice(state = {}) {
canLoadMore: false, 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 { return {
visible: true, 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, canLoadMore: true,
}; };
} }
@@ -1949,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call. * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/ */
const FILE_PATH_LINK_PATTERN = 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}. */ /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() { function absoluteFilePathPattern() {
@@ -1961,13 +2059,18 @@ function absoluteFilePathPattern() {
* points at goes to the tail/log viewer, which is the right home for a growing * 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). * 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 * The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
* (src/attachment-registry.ts, the single source) — they diverged once and an * (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 * 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. * file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/ */
const FILE_PREVIEW_EXTENSIONS = new Set( 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. */ /** 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); 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 * 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 }; 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) // Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -2992,8 +3372,16 @@ function describeSessionHarness(session, catalog) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS }; window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; window.CodemanFilePaths = {
window.CodemanTerminalLines = { terminalLogicalLine }; absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanUrlSession = { sessionIdFromFragment }; window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = { window.CodemanSplitPane = {
clampDividerPercent, clampDividerPercent,
+31 -2
View File
@@ -93,6 +93,16 @@
'Move down': '下移', 'Move down': '下移',
'Move to Ungrouped': '移到未分组', 'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组', '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.': '无法保存标签分组。', 'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.': 'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。', '标签分组已在别处更改;你的部分编辑已不再适用。',
@@ -158,8 +168,9 @@
'Restore the grid': '恢复平铺网格', 'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处', 'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js). // A file of an unsupported type dropped on a tile (tile-grid.js); the single
'Only image files are supported': '仅支持图像文件', // 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. // Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话', 'No active session': '没有活动会话',
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定', 'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
@@ -474,6 +485,9 @@
'Ultracode Floating Windows': 'Ultracode 浮动窗口', 'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱', 'Approvals Inbox': '审批收件箱',
'Auto-name Sessions': '自动命名会话', '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: '审批', Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示', 'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批', 'No pending approvals': '没有待处理的审批',
@@ -676,6 +690,8 @@
// placeholder and label are looked up by the module at build time, since // placeholder and label are looked up by the module at build time, since
// the DOM translator skips <textarea> subtrees. // the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词', 'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'Only image or video files are supported': '仅支持图片或视频文件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存', 'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿', 'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行', 'Enter adds a new line': '按 Enter 换行',
@@ -712,6 +728,16 @@
'Session Color': '会话颜色', 'Session Color': '会话颜色',
'Working Directory': '工作目录', 'Working Directory': '工作目录',
'Set 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': '继续对话', 'Resume Conversation': '继续对话',
'Close Session': '关闭会话', 'Close Session': '关闭会话',
'Choose how to close': '选择关闭方式', 'Choose how to close': '选择关闭方式',
@@ -1228,6 +1254,9 @@
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`], [/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Group names are user text: they pass through untranslated. // Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], [/^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\.$/, /^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
+185 -15
View File
@@ -14,6 +14,69 @@
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
initImageInput() { 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 // Drag-and-drop handlers on terminal container
const container = document.getElementById('terminalContainer'); const container = document.getElementById('terminalContainer');
if (!container) return; if (!container) return;
@@ -38,15 +101,100 @@ Object.assign(CodemanApp.prototype, {
if (!this.activeSessionId) return; if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return; if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/')); const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
if (imageFiles.length === 0) { if (mediaFiles.length === 0) {
this.showToast('Only image files are supported', 'error'); this.showToast('Only image or video files are supported', 'error');
return; 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. // Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects // 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). // the result for images. Works on plain HTTP (no Clipboard API needed).
@@ -82,15 +230,13 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return; if (pasteConsumed) return;
pasteConsumed = true; pasteConsumed = true;
// Check for images in clipboard items // Check for images or videos in clipboard items
var imageFiles = []; var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items; var items = e.clipboardData && e.clipboardData.items;
if (items) { if (items) {
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) { var blob = items[i].getAsFile();
var blob = items[i].getAsFile(); if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
if (blob) imageFiles.push(blob);
}
} }
} }
@@ -120,6 +266,21 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste'); 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 // 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 // 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". // rate limit while covering "select a bunch of photos at once".
@@ -136,6 +297,7 @@ Object.assign(CodemanApp.prototype, {
async _uploadAndInsertImages(fileList, options = {}) { async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = options.sessionId || this.activeSessionId; const sessionId = options.sessionId || this.activeSessionId;
if (!sessionId) return []; if (!sessionId) return [];
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
let files = Array.from(fileList || []); let files = Array.from(fileList || []);
if (files.length === 0) return []; 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 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 results = new Array(total); // preserve selection order for insertion
const progress = () => 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(); progress();
// Bounded-concurrency worker pool over the file list. // 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. // Final status: successes, plus any failures / cap so nothing is silent.
const parts = []; 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 (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
if (capped) parts.push(`max ${this._maxBatchImages} per batch`); if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error'; 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; return paths;
}, },
@@ -209,7 +371,10 @@ Object.assign(CodemanApp.prototype, {
const form = new FormData(); const form = new FormData();
form.append('image', file); 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', method: 'POST',
body: form, body: form,
}); });
@@ -231,9 +396,14 @@ Object.assign(CodemanApp.prototype, {
// decode/encode failure the original file is returned unchanged so the server // decode/encode failure the original file is returned unchanged so the server
// still gets a chance (and logs a precise diagnostic). // still gets a chance (and logs a precise diagnostic).
async _normalizeImageForUpload(file) { 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); const url = URL.createObjectURL(file);
try { try {
const img = new Image(); const img = new Image();
+81 -7
View File
@@ -614,7 +614,11 @@
<div class="file-preview-header"> <div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span> <span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions"> <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="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="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" 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> <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>
</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) --> <!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
<div class="response-viewer" id="responseViewer"> <div class="response-viewer" id="responseViewer">
<div class="response-viewer-header"> <div class="response-viewer-header">
@@ -757,6 +777,21 @@
</div> </div>
</div> </div>
<!-- Mobile-only case button + gear --> <!-- 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()" <button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
title="Voice input" aria-label="Start voice input" aria-pressed="false"> 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"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1287,6 +1322,22 @@
<!-- Hidden wholesale for non-claude sessions and for a session with <!-- Hidden wholesale for non-claude sessions and for a session with
no live process (session-ui.js: openSessionOptions). --> no live process (session-ui.js: openSessionOptions). -->
<div id="sessionRespawnSection"> <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">
<div class="set-group-head"><h4>Loop control</h4></div> <div class="set-group-head"><h4>Loop control</h4></div>
<div class="set-group-body"> <div class="set-group-body">
@@ -1408,6 +1459,16 @@
<input type="text" id="modalSessionName" class="set-input" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()"> <input type="text" id="modalSessionName" class="set-input" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
</div> </div>
</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 set-row-block">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Session color</span> <span class="set-row-label">Session color</span>
@@ -2255,6 +2316,13 @@
</div> </div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label> <label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
</div> </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" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span> <span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
@@ -2605,6 +2673,19 @@
</div> </div>
</div> </div>
<div class="set-group" id="agentSkillGroup">
<div class="set-group-head"><h4>All agent CLIs</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
<div class="set-row-text">
<span class="set-row-label">Codeman Agent Skill</span>
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group"> <div class="set-group">
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div> <div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
<div class="set-group-body"> <div class="set-group-body">
@@ -2641,13 +2722,6 @@
</div> </div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label> <label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label>
</div> </div>
<div class="set-row" data-search="agent skill codeman api workers">
<div class="set-row-text">
<span class="set-row-label">Codeman Agent Skill</span>
<span class="set-row-desc">Let new sessions start workers, send prompts and wait for results over the API.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json"> <div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Workspace Hooks</span> <span class="set-row-label">Workspace Hooks</span>
+115 -35
View File
@@ -636,12 +636,13 @@ function applyOneShotCtrl(data) {
return { data, consumed: true }; return { data, consumed: true };
} }
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket // The composer's Send, and the shell Paste dialog's, goes out as ONE
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH // bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT // frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB,
// an ACK, which would wedge the durable input queue. So the prompt budget is // compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable
// that limit minus the two markers, derived here once so the refusal in // input queue. So the budget is that limit minus the two markers, derived here
// _sendComposedPrompt() and the toast that names the maximum cannot drift. // once so the refusal in _sendBracketedPaste() and the toast that names the
// maximum cannot drift.
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024; const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
const COMPOSER_PASTE_START = '\x1b[200~'; const COMPOSER_PASTE_START = '\x1b[200~';
const COMPOSER_PASTE_END = '\x1b[201~'; const COMPOSER_PASTE_END = '\x1b[201~';
@@ -664,7 +665,7 @@ const KeyboardAccessoryBar = {
_composerDrafts: new Map(), _composerDrafts: new Map(),
_composerUploads: new Map(), _composerUploads: new Map(),
_composerOverlay: null, _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, _composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */ /** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
@@ -1205,23 +1206,35 @@ const KeyboardAccessoryBar = {
textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('input', { bubbles: true }));
}, },
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses /** Deliver one complete prompt as an explicit bracketed paste
* its DECSET 2004 mirror after terminal replay, even though the CLI still * (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror
* expects bracketed input, so build the byte-identical sequence directly on * after terminal replay, even though the CLI still expects bracketed input,
* the durable session-bound path. Enter stays a separate delayed write * so the frame is built directly rather than through terminal.paste().
* because Codex drops keys sharing a PTY read with a bracketed paste. */ * Enter stays a separate delayed write because Codex drops keys sharing a
* PTY read with a bracketed paste. */
_sendComposedPrompt(sessionId, text) { _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; if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
app._predictiveEcho?.clearPredictions(); app._predictiveEcho?.clearPredictions();
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside // Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
// the single-line input transport and is what terminal.paste() emitted. // 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) { 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; return false;
} }
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`); app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true; return true;
}, },
@@ -1247,7 +1260,11 @@ const KeyboardAccessoryBar = {
const sessionId = app.activeSessionId; const sessionId = app.activeSessionId;
const pending = this._takePendingLocalEcho(sessionId); const pending = this._takePendingLocalEcho(sessionId);
const saved = this._composerDrafts.get(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 }); this._composerOverlay?._closeComposer?.({ restoreFocus: false });
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -1269,7 +1286,7 @@ const KeyboardAccessoryBar = {
<button type="button" class="paste-cancel">Cancel</button> <button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button> <button type="button" class="paste-send">Send</button>
</div> </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> </div>
`; `;
@@ -1285,6 +1302,10 @@ const KeyboardAccessoryBar = {
const fileInput = overlay.querySelector('.paste-file-input'); const fileInput = overlay.querySelector('.paste-file-input');
const imageButton = overlay.querySelector('.paste-image'); const imageButton = overlay.querySelector('.paste-image');
const sendButton = overlay.querySelector('.paste-send'); 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); const focusTrap = new FocusTrap(overlay);
textarea.value = initial; textarea.value = initial;
if (initial) this._composerDrafts.set(sessionId, initial); if (initial) this._composerDrafts.set(sessionId, initial);
@@ -1292,6 +1313,7 @@ const KeyboardAccessoryBar = {
const initialUploads = this._composerUploads.get(sessionId) || 0; const initialUploads = this._composerUploads.get(sessionId) || 0;
imageButton.disabled = initialUploads > 0; imageButton.disabled = initialUploads > 0;
sendButton.disabled = initialUploads > 0; sendButton.disabled = initialUploads > 0;
terminalButton.disabled = initialUploads > 0;
if (initialUploads > 0) imageButton.textContent = 'Uploading…'; if (initialUploads > 0) imageButton.textContent = 'Uploading…';
const saveDraft = () => { const saveDraft = () => {
@@ -1318,7 +1340,7 @@ const KeyboardAccessoryBar = {
close({ preserveDraft: false }); close({ preserveDraft: false });
}; };
const handleImages = async (files) => { 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; if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
saveDraft(); saveDraft();
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1); this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
@@ -1335,6 +1357,8 @@ const KeyboardAccessoryBar = {
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image'; currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
} }
if (currentSendButton) currentSendButton.disabled = count > 0; if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
}; };
syncUploadUi(); syncUploadUi();
try { try {
@@ -1366,9 +1390,8 @@ const KeyboardAccessoryBar = {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
const images = Array.from(items) const images = Array.from(items)
.filter((item) => item.type.startsWith('image/'))
.map((item) => item.getAsFile()) .map((item) => item.getAsFile())
.filter(Boolean); .filter((file) => file && app._promptAttachKind(file));
if (images.length > 0) { if (images.length > 0) {
event.preventDefault(); event.preventDefault();
void handleImages(images); void handleImages(images);
@@ -1382,7 +1405,55 @@ const KeyboardAccessoryBar = {
}); });
imageButton.addEventListener('click', () => fileInput.click()); imageButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => void handleImages(fileInput.files)); 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()); overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
sendButton.addEventListener('click', send); sendButton.addEventListener('click', send);
overlay.addEventListener('click', (event) => { overlay.addEventListener('click', (event) => {
@@ -1399,7 +1470,7 @@ const KeyboardAccessoryBar = {
* Handles three input paths from one dialog: * Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged). * - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker * - 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. * way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's * - Image (paste): if the browser exposes image blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty * 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. */ * uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() { pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return; 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 // Create overlay
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'paste-overlay'; overlay.className = 'paste-overlay';
overlay.innerHTML = ` overlay.innerHTML = `
<div class="paste-dialog"> <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"> <div class="paste-actions">
<button class="paste-image">🖼 Image</button> <button class="paste-image">🖼 Image</button>
<button class="paste-cancel">Cancel</button> <button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button> <button class="paste-send">Send</button>
</div> </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> </div>
`; `;
@@ -1429,22 +1503,30 @@ const KeyboardAccessoryBar = {
const close = () => overlay.remove(); 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 sendText = () => {
const text = textarea.value; const text = textarea.value;
close(); if (!text) {
if (text) { close();
app.sendInput(text); return;
setTimeout(() => app.sendInput('\r'), 80);
} }
// 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. // upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => { 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; if (images.length === 0) return false;
close(); 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; return true;
}; };
@@ -1458,10 +1540,8 @@ const KeyboardAccessoryBar = {
if (!items) return; if (!items) return;
const imageFiles = []; const imageFiles = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) { const blob = items[i].getAsFile();
const blob = items[i].getAsFile(); if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
if (blob) imageFiles.push(blob);
}
} }
if (imageFiles.length > 0) { if (imageFiles.length > 0) {
e.preventDefault(); e.preventDefault();
+11 -2
View File
@@ -794,7 +794,7 @@ Object.assign(CodemanApp.prototype, {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'mobile-overview-row-wrap'; wrap.className = 'mobile-overview-row-wrap';
wrap.appendChild(item); wrap.appendChild(item);
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval)); wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval, row));
return wrap; return wrap;
} }
@@ -949,7 +949,7 @@ Object.assign(CodemanApp.prototype, {
}, },
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */ /** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
_buildMobileOverviewApprovalStrip(approval) { _buildMobileOverviewApprovalStrip(approval, row) {
const strip = document.createElement('div'); const strip = document.createElement('div');
strip.className = 'mobile-overview-approval-strip'; strip.className = 'mobile-overview-approval-strip';
strip.setAttribute('data-i18n-skip', ''); strip.setAttribute('data-i18n-skip', '');
@@ -964,6 +964,15 @@ Object.assign(CodemanApp.prototype, {
}); });
strip.appendChild(btn); 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) { if (approval.options && approval.options.length) {
for (const o of approval.options) { for (const o of approval.options) {
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label; 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, /* Mobile voice input button in toolbar — initially hidden via inline style,
shown by VoiceInput._showButtons() clearing the inline display */ 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; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -626,15 +628,48 @@ html.mobile-init .file-browser-panel {
color: #93c5fd; color: #93c5fd;
cursor: pointer; cursor: pointer;
flex-shrink: 0; 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; width: 13px;
height: 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; 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 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; 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. */ 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); background: var(--control-bg);
border-color: var(--control-border); border-color: var(--control-border);
color: var(--text-dim); 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); background: var(--control-bg-hover);
border-color: var(--control-border-hover); border-color: var(--control-border-hover);
color: var(--text); 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). // would be dropped on the next save).
const FILE_PREVIEW_PREF_KEYS = { const FILE_PREVIEW_PREF_KEYS = {
mdRendered: 'codeman:filePreviewMdRendered', mdRendered: 'codeman:filePreviewMdRendered',
htmlRendered: 'codeman:filePreviewHtmlRendered',
lineNumbers: 'codeman:filePreviewLineNumbers', lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap', 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 = [ const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'], ['needsAttention', 'Needs Attention'],
['completed', 'Completed'], ['completed', 'Completed'],
@@ -4037,6 +4038,9 @@ Object.assign(CodemanApp.prototype, {
* unknown workingDir answers false, leaving the historical path untouched. * unknown workingDir answers false, leaving the historical path untouched.
*/ */
_isExternalPreviewPath(filePath, sessionId) { _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; if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
const workingDir = this.sessions.get(sessionId)?.workingDir; const workingDir = this.sessions.get(sessionId)?.workingDir;
if (!workingDir) return false; 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; 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 overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle'); const titleEl = this.$('filePreviewTitle');
@@ -4093,6 +4108,12 @@ Object.assign(CodemanApp.prototype, {
if (!overlay || !bodyEl) return; 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. // Edit mode: reset any prior editor state whenever a preview (re)loads.
this._resetFilePreviewEdit(); this._resetFilePreviewEdit();
// Stop whatever the previous preview was playing. Overwriting innerHTML // 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 // 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. // fetched yet, and an image or PDF has nothing for them to toggle.
this.filePreviewText = null; this.filePreviewText = null;
this.filePreviewPage = null;
this.filePreviewPageRefused = false;
this._updateFilePreviewToolbar('none'); this._updateFilePreviewToolbar('none');
// Show overlay with loading state // Show overlay with loading state
@@ -4116,14 +4139,36 @@ Object.assign(CodemanApp.prototype, {
const ext = (filePath.split('.').pop() || '').toLowerCase(); 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 // 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 // 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" // render a broken frame and the text one reports a bare "File not found"
// for a file that is sitting right there on disk. // for a file that is sitting right there on disk.
let externalError = ''; let externalError = '';
let externalSize = 0; let externalSize = 0;
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) { if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
const external = await this._registerExternalPreview(filePath, sessionId); const external = await this._registerExternalPreview(filePath, sessionId);
if (stale()) return;
attachmentId = external.attachmentId || null; attachmentId = external.attachmentId || null;
externalError = external.error || ''; externalError = external.error || '';
externalSize = external.size || 0; 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 // (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.) // attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx'; 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( this.filePreviewDetachUrl = CodemanBase.url(
attachmentId byIdBase
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}` ? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc : officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}` ? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?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 // Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched // PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.) // raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) { // A folder-grid file renders the same way, from its folder's by-name routes.
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`); if (byIdBase) {
const base = CodemanBase.url(byIdBase);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']); const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS // VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import // (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 // 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 // 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. // 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(); footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) { if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`; 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}` } }); 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'); if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text(); const text = await res.text();
if (stale()) return;
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES; const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
const lines = text.split('\n'); const lines = text.split('\n');
// Markdown keeps every line the Range read returned: the byte bound is // Markdown keeps every line the Range read returned: the byte bound is
@@ -4201,13 +4254,14 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = shown; this.filePreviewContent = shown;
// attachmentId: a card's filePath is the bare file name, so the // attachmentId: a card's filePath is the bare file name, so the
// rebase pass must know there is no directory to resolve against. // 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(); this._renderFilePreviewText();
if (clippedByLines || clippedByBytes) { if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file'; const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
footerEl.textContent = `${footerEl.textContent} (${note})`; footerEl.textContent = `${footerEl.textContent} (${note})`;
} }
} catch (err) { } catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`; bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
} }
} }
@@ -4240,11 +4294,14 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`); const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed to load image'); 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)}">`; bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
const img = bodyEl.querySelector('img'); const img = bodyEl.querySelector('img');
if (img) img.onload = () => URL.revokeObjectURL(blobUrl); if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
} catch (err) { } catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`; bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
} }
return; return;
@@ -4260,6 +4317,7 @@ Object.assign(CodemanApp.prototype, {
if (!res.ok) throw new Error('Failed to load file'); if (!res.ok) throw new Error('Failed to load file');
const result = await res.json(); const result = await res.json();
if (stale()) return;
if (!result.success) throw new Error(result.error || 'Failed to load file'); if (!result.success) throw new Error(result.error || 'Failed to load file');
const data = result.data; const data = result.data;
@@ -4299,14 +4357,266 @@ Object.assign(CodemanApp.prototype, {
} }
} }
} catch (err) { } catch (err) {
if (stale()) return;
console.error('Failed to preview file:', err); console.error('Failed to preview file:', err);
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`; 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() { closeFilePreview() {
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return; if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
this._resetFilePreviewEdit(); 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'); const overlay = this.$('filePreviewOverlay');
if (overlay) { if (overlay) {
overlay.classList.remove('visible'); 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 * 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, * either view, Page for an HTML file in either view (`'page'` = rendered),
* image, media, PDF, edit mode) hides all three. * Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
* PDF, edit mode) hides them all.
*/ */
_updateFilePreviewToolbar(view) { _updateFilePreviewToolbar(view) {
const set = (id, shown, pressed) => { const set = (id, shown, pressed) => {
@@ -4583,10 +4894,42 @@ Object.assign(CodemanApp.prototype, {
}; };
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || ''); const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
set('filePreviewMdBtn', isMarkdown, view === 'markdown'); 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('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap')); 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() { toggleFilePreviewMd() {
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered')); this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
this._renderFilePreviewText(); this._renderFilePreviewText();
+79 -23
View File
@@ -621,9 +621,12 @@ Object.assign(CodemanApp.prototype, {
/** Ensure a newly-created session is visible without waiting for the SSE event. /** 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 * The POST response and session:created can arrive in either order, so the
* normal idempotent SSE handler remains the single state-upsert path. */ * normal idempotent SSE handler remains the single state-upsert path.
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) { * `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 (!sessionId) return;
if (tabLayout) this._applyTabLayout?.(tabLayout);
let session = sessionSnapshot; let session = sessionSnapshot;
if (!session && !this.sessions?.has(sessionId)) { if (!session && !this.sessions?.has(sessionId)) {
@@ -643,8 +646,13 @@ Object.assign(CodemanApp.prototype, {
this._renderSessionTabsImmediate?.(); 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; if (this._runInFlight) return;
const startedAt = Date.now(); const startedAt = Date.now();
@@ -659,12 +667,12 @@ Object.assign(CodemanApp.prototype, {
try { try {
const mode = this._runMode || 'claude'; const mode = this._runMode || 'claude';
if (mode === 'shell') { if (mode === 'shell') {
return await this.runShell(); return await this.runShell(options);
} }
if (mode === 'claude' || !isExternalCliRunMode(mode)) { 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 { } finally {
const remaining = minLockMs - (Date.now() - startedAt); const remaining = minLockMs - (Date.now() - startedAt);
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
@@ -1108,7 +1116,7 @@ Object.assign(CodemanApp.prototype, {
* *
* Routes through run() itself, via a temporary `_runMode` swap, rather than a * Routes through run() itself, via a temporary `_runMode` swap, rather than a
* parallel dispatch table: that is what gives this the same in-flight lock * parallel dispatch table: that is what gives this the same in-flight lock
* every other Run click gets (CLAUDE.md, Run launch synchronization — the lock * every other Run click gets (src/web/CLAUDE.md, Run launch synchronization — the lock
* exists so a double click cannot create duplicate sessions with the same * exists so a double click cannot create duplicate sessions with the same
* `w<n>-<case>` name, and it guards the OTHER direction too: without it, the * `w<n>-<case>` name, and it guards the OTHER direction too: without it, the
* main Run button could start a second concurrent launch while this one was * main Run button could start a second concurrent launch while this one was
@@ -1910,9 +1918,10 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
async runClaude() { async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase'; 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( const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...` `Starting ${tabCount} Claude session(s) in ${caseName}...`
@@ -1972,6 +1981,7 @@ Object.assign(CodemanApp.prototype, {
for (let i = 0; i < tabCount; i++) { for (let i = 0; i < tabCount; i++) {
const quickStartBody = JSON.stringify({ const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`, caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...group,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {}) ...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
}); });
const doQuickStart = async () => { 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'); 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); remoteIds.push(data.data.sessionId);
} }
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
@@ -2046,6 +2056,7 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
workingDir, name, workingDir, name,
...group,
...(hasEnvOverrides ? { envOverrides } : {}), ...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}), ...(advisorModel ? { advisorModel } : {}),
@@ -2059,7 +2070,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = []; const sessionIds = [];
for (const result of createResults) { for (const result of createResults) {
if (!result.success) throw new Error(result.error); 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); sessionIds.push(result.data.session.id);
} }
firstSessionId = sessionIds[0]; firstSessionId = sessionIds[0];
@@ -2128,11 +2139,12 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
async runShell() { async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase'; const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Run Shell reads the toolbar's one instance stepper, like every other run*(); // Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428). // 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( const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`, `Starting ${shellCount} Shell session(s) in ${caseName}...`,
@@ -2178,11 +2190,11 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch('/api/quick-start', { const res = await fetch('/api/quick-start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(); const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session'); 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); remoteIds.push(data.data.sessionId);
} }
if (remoteIds[0]) { if (remoteIds[0]) {
@@ -2208,7 +2220,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', { fetch('/api/sessions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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()) }).then(r => r.json())
); );
const createResults = await Promise.all(createPromises); const createResults = await Promise.all(createPromises);
@@ -2216,7 +2228,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = []; const sessionIds = [];
for (const result of createResults) { for (const result of createResults) {
if (!result.success) throw new Error(result.error); 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); 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 * 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. * unconditionally today, but a throw here would escape the launch-error path.
*/ */
_readTabCount() { _readTabCount(override) {
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1)); 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}`); if (!data.success) throw new Error(data.error || `Failed to start ${label}`);
customModelAnswered = true; 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 (!firstSessionId) firstSessionId = data.data.sessionId;
} }
if (tabCount > 1) { if (tabCount > 1) {
@@ -2309,7 +2321,7 @@ Object.assign(CodemanApp.prototype, {
* menu call them directly by name (`app.runOpenCode()` etc.), and several * menu call them directly by name (`app.runOpenCode()` etc.), and several
* tests assert on that name directly too. * tests assert on that name directly too.
*/ */
async _runCliMode(mode) { async _runCliMode(mode, options = {}) {
const catalogEntry = registryCliById(mode); const catalogEntry = registryCliById(mode);
const entry = RUN_MODE_LAUNCH[mode] || const entry = RUN_MODE_LAUNCH[mode] ||
(catalogEntry && { (catalogEntry && {
@@ -2326,7 +2338,7 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount(); const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus( const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...` `Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
); );
@@ -2364,6 +2376,7 @@ Object.assign(CodemanApp.prototype, {
caseName, caseName,
mode, mode,
sessionName, sessionName,
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : { ...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}), ...(entry.buildConfig(globalSettings) || {}),
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
@@ -2473,6 +2486,14 @@ Object.assign(CodemanApp.prototype, {
if (tab) tab.classList.toggle('tab-show-detach', !!on); 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) { openSessionOptions(sessionId) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
@@ -2534,6 +2555,7 @@ Object.assign(CodemanApp.prototype, {
// Populate auto-resume on usage limit (token pause control) // Populate auto-resume on usage limit (token pause control)
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false; document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId); this.updateAutoResumeStatus(sessionId);
this.updatePromptCacheStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -2552,6 +2574,19 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)'; 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 // Initialize color picker with current session color
const currentColor = session.color || 'default'; const currentColor = session.color || 'default';
const colorPicker = document.getElementById('sessionColorPicker'); 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() { async toggleSessionImageWatcher() {
if (!this.editingSessionId) return; if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked; const enabled = document.getElementById('modalImageWatcherEnabled').checked;
+3
View File
@@ -442,6 +442,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true; document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables). // Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true; 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'); const lineageItem = document.getElementById('appSettingsLineageLinesItem');
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false; document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
@@ -2587,6 +2589,7 @@ Object.assign(CodemanApp.prototype, {
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked, mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked, sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked, autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked, showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked, showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked, showCronButton: document.getElementById('appSettingsShowCronButton').checked,
@@ -171,6 +171,17 @@ async function loadWorkbook(bytes) {
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather // defineProperty throws if a future ExcelJS renames `_definedNames`, rather
// than silently expanding again. // than silently expanding again.
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} }); 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 // ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows // 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 // validations, so they are not parsed at all. `maxRows` is a per-sheet
+1 -1
View File
@@ -20,7 +20,7 @@
(function initSpreadsheetPreview(global) { (function initSpreadsheetPreview(global) {
'use strict'; 'use strict';
const SPREADSHEET_ASSET_VERSION = '911680fac09d'; const SPREADSHEET_ASSET_VERSION = 'fd81c01bef7b';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024; const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000; const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000; const MAX_SCROLL_PX = 8000000;
+110
View File
@@ -472,6 +472,115 @@
return zipApi.zipSync(admission.entries, { level: 0 }); 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) { function parseCellRef(ref) {
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || '')); const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
if (!match) return null; if (!match) return null;
@@ -983,6 +1092,7 @@
createXmlCounter, createXmlCounter,
admitXlsx, admitXlsx,
buildAdmittedArchive, buildAdmittedArchive,
repairExcelJsModel,
excelJsEntryName, excelJsEntryName,
parseCellRef, parseCellRef,
parseRange, parseRange,
+290 -1
View File
@@ -716,6 +716,19 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
text-align: left; 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 { html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -815,6 +828,141 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layo
user-select: none; 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 { html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
cursor: grabbing; cursor: grabbing;
} }
@@ -2927,6 +3075,14 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions
color: var(--red); 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 { :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
display: grid; display: grid;
grid-template-columns: minmax(112px, 1fr) 44px; grid-template-columns: minmax(112px, 1fr) 44px;
@@ -6004,6 +6160,12 @@ html[data-skin="og"] .welcome-primary:hover {
display: none; 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 */ /* Mobile case settings gear - hidden on desktop */
.btn-case-settings-mobile { .btn-case-settings-mobile {
display: none; display: none;
@@ -11383,6 +11545,132 @@ kbd {
display: flex; 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 { .file-preview-window {
width: 80vw; width: 80vw;
max-width: 900px; max-width: 900px;
@@ -19394,7 +19682,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
/* No gutter of their own. */ /* No gutter of their own. */
.modal, .modal,
.file-preview-overlay { .file-preview-overlay,
.folder-grid-overlay {
padding-right: var(--fold-inline-end); padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-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('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return; if (request.headers.get('accept') === 'text/event-stream') return;
if (request.url.includes('/api/')) 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( event.respondWith(
fetch(request) fetch(request)
+196 -10
View File
@@ -13,7 +13,7 @@
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed * 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry. * read degrades to the flat rail with a capped, backed-off retry.
* 4. Editing: named operations (create/rename/delete/reorder a group, move a * 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 * `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
* version conflict. * version conflict.
* *
@@ -32,17 +32,26 @@
'use strict'; 'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed'; 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 refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) => const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0; !!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 asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))]; const stableIds = (value) => [...new Set(asIds(value))];
// `placement: 'manual'` must survive the round trip: the browser writes whole // `placement: 'manual'` and `focus: true` must survive the round trip: the
// layouts back, and dropping it would re-attach a hand-placed child session to // browser writes whole layouts back, so a field dropped here is erased on the
// its parent's subtree on the next save. // next save, by whoever saves. Losing placement re-attaches a hand-placed child
const copyRef = (r) => // session to its parent's subtree; losing focus unpins every Focus shortcut.
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id }; 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) : []); 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. */ /** 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) { function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds); const ids = stableIds(groupIds);
try { try {
@@ -131,11 +186,19 @@
* tab, else the active session), so selecting a hidden session by keyboard, * tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection. * 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 * 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 * unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row * collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden. * (`<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 = {}) { function project(layoutInput, options = {}) {
if (!layoutInput) return null; if (!layoutInput) return null;
@@ -152,6 +215,7 @@
? `session:${options.activeSessionId}` ? `session:${options.activeSessionId}`
: ''; : '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id)); 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 placed = new Set();
const visibleRefs = []; const visibleRefs = [];
const hiddenTabGroupByRef = {}; const hiddenTabGroupByRef = {};
@@ -182,14 +246,14 @@
for (const group of layout.groups) { for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id); 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 }); sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
} }
const omissions = [ const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })), ...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', 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) { if (ungrouped.count > 0) {
sections.push({ sections.push({
id: null, id: null,
@@ -200,7 +264,7 @@
collapsed: false, collapsed: false,
}); });
} }
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }; return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
} }
const ALERT_RANK = { action: 2, idle: 1 }; const ALERT_RANK = { action: 2, idle: 1 };
@@ -227,6 +291,47 @@
return result; 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 * 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 * where, the headers' names, what a collapse hides), as opposed to a row's own
@@ -418,6 +523,35 @@
return keys; 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 * 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 * 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); layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout; 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': { case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row'); if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref); const targetKey = refKey(op.ref);
@@ -546,6 +691,43 @@
destination.splice(clampIndex(op.index, destination.length), 0, ...block); destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout; 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: default:
return editError(`unknown operation ${op.type}`); return editError(`unknown operation ${op.type}`);
} }
@@ -739,12 +921,16 @@
applyOperation, applyOperation,
moveDestination, moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)], movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
placementState,
dropOperation, dropOperation,
contentKey, contentKey,
createEditCoordinator, createEditCoordinator,
createLoadCoordinator, createLoadCoordinator,
loadCollapsedGroupIds, loadCollapsedGroupIds,
saveCollapsedGroupIds, saveCollapsedGroupIds,
focusRefs,
loadFocusCollapsed,
saveFocusCollapsed,
MAX_GROUPS, MAX_GROUPS,
}; };
})(typeof window !== 'undefined' ? window : globalThis); })(typeof window !== 'undefined' ? window : globalThis);
+14 -1
View File
@@ -327,7 +327,8 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const actions = [ const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) }, { 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._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
// A tile (Detach Tiles on) opens its own size, over where it is: the // 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. // keyboard's way to what dragging its header out of the window does.
@@ -340,6 +341,18 @@ Object.assign(CodemanApp.prototype, {
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) }, { label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
]; ];
for (const action of actions) { 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'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.setAttribute('role', 'menuitem'); button.setAttribute('role', 'menuitem');
+44 -24
View File
@@ -25,13 +25,15 @@
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op. * 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 * - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback, * on the alternate screen, Claude's repaint mode) leaves the xterm no
* so the wheel pages the CLI's own transcript with PageUp/PageDown * scrollback, so the wheel pages the CLI's own transcript with
* (_maybePageCliTranscript) through the primary pane's gates, plus an * PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
* overflow-row discount for this pane's capture-before-resize load * and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
* (_localRows), and only while the viewport is on the live screen (a * an overflow-row discount for this pane's capture-before-resize load
* wheel-down from those overflow rows is xterm's, and brings it home). * (_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 desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes * the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener), sent * whose mouse DECSETs the server strips (_installClickListener), sent
@@ -48,8 +50,8 @@
* Android tablet, or a large foldable unfolded in landscape, reaches. * Android tablet, or a large foldable unfolded in landscape, reaches.
* *
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @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 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/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick) * @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) * @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 * @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). // flag, app._linkHovered, belongs to its terminal alone).
this._linkHovered = false; this._linkHovered = false;
this._onFocusIn = null; this._onFocusIn = null;
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a // Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
// whole page, carried to the next wheel event. // (travel short of a whole page, its direction and last event time), the
this._pageKeyPending = 0; // state CodemanTerminalInput.pageKeysForGesture hands back.
this._pageKeyGesture = null;
// Shift+wheel travel short of a whole line, carried to the next wheel // Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift). // event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0; this._shiftScrollPending = 0;
@@ -243,6 +246,9 @@
this.fitAddon = new FitAddon.FitAddon(); this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.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.terminal.open(this.mountEl);
this.fitAddon.fit(); this.fitAddon.fit();
@@ -770,8 +776,15 @@
try { try {
if (this._destroyed) return; if (this._destroyed) return;
const shell = this.sessionMode === 'shell'; const shell = this.sessionMode === 'shell';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; // A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`; // 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; this._loadAbort = controller;
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000); armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
let payload; let payload;
@@ -1147,17 +1160,20 @@
// Hollow-buffer paging, the twin of the primary pane's // Hollow-buffer paging, the twin of the primary pane's
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that // _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
// draws in place (opencode, on the alternate screen; Claude's repaint mode) // draws in place (on the alternate screen, or Claude's repaint mode) leaves
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the // this xterm no scrollback, so a wheel scrolled nothing; instead the travel
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is // pages the CLI's own transcript with PageUp/PageDown. Every gate is the
// the primary pane's own, asked for THIS pane (its terminal, its session, // primary pane's own, asked for THIS pane (its terminal, its session, never
// never the active one), so the CLI rules stay in terminal-ui.js and this // the active one), so the CLI rules stay in terminal-ui.js and this file
// file names no CLI. Returns true when the wheel was consumed here. // 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) { _maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false; if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
const app = global.app; const app = global.app;
const input = global.CodemanTerminalInput; 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 // xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it (a shell running htop), as in the primary pane. // it (a shell running htop), as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode; const tracking = this.terminal.modes?.mouseTrackingMode;
@@ -1181,8 +1197,12 @@
if (!app._terminalViewportAtBottom?.(this.terminal)) return false; if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
const lines = input.wheelDeltaLines(ev, this.terminal.rows); const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return false; if (!lines) return false;
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); // An open dialog (a pending permission prompt or question) in THIS tile's
this._pageKeyPending = step.pending; // 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); if (step.keys) this._queueScrollBytes(step.keys);
return true; return true;
} }
@@ -1536,7 +1556,7 @@
clearTimeout(this._scrollFlushTimer); clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null; this._scrollFlushTimer = null;
this._scrollBytes = ''; this._scrollBytes = '';
this._pageKeyPending = 0; this._pageKeyGesture = null;
this._detachSocket(); this._detachSocket();
if (this._onFocusIn) { if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
+299 -56
View File
@@ -5,7 +5,7 @@
* *
* @mixin Extends CodemanApp.prototype via Object.assign * @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions) * @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 mobile-handlers.js (MobileDetection)
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
* @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay) * @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay)
@@ -79,7 +79,7 @@
const REPLAY_ESCAPE_RE = const REPLAY_ESCAPE_RE =
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g; /\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 // 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). // (_maybePageCliTranscript).
const KEY_PAGE_UP = '\x1b[5~'; const KEY_PAGE_UP = '\x1b[5~';
const KEY_PAGE_DOWN = '\x1b[6~'; const KEY_PAGE_DOWN = '\x1b[6~';
@@ -92,6 +92,20 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick // 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. // cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3; 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 // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for 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); 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 // Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel // `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 // left over plus the keys to send ('' below one page). The plain accumulation
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps // step of pageKeysForGesture below.
// its own pending travel) pages identically.
function pageKeysForTravel(pending, lines, rows) { 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 total = (pending || 0) + lines;
const pages = Math.trunc(total / perPage); const pages = Math.trunc(total / perPage);
const keys = pages const keys = pages
@@ -148,6 +166,77 @@
return { pending: total - pages * perPage, keys }; 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; const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and // 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 // 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, SGR_WHEEL_MAX_TICKS,
sgrWheelReports, sgrWheelReports,
pageKeysForTravel, 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, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP, MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -612,6 +706,8 @@ Object.assign(CodemanApp.prototype, {
} }
} }
this._installOsc52Clipboard(this.terminal);
const container = document.getElementById('terminalContainer'); const container = document.getElementById('terminalContainer');
this.terminal.open(container); this.terminal.open(container);
this._initMobileImePreview(); this._initMobileImePreview();
@@ -1056,6 +1152,10 @@ Object.assign(CodemanApp.prototype, {
let lastTime = 0; let lastTime = 0;
let scrollFrame = null; let scrollFrame = null;
let isTouching = false; 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 scrollLoop = (timestamp) => {
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; 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 // Flick momentum keeps feeding the CLI's transcript from the last
// touch point; the 40ms coalescer batches the per-frame reports. // touch point; the 40ms coalescer batches the per-frame reports.
this._forwardScrollToApp(touchLastX, touchLastY, lines); 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.terminal.scrollLines(lines);
this._maybeLoadMoreHistoryOnScroll(lines); this._maybeLoadMoreHistoryOnScroll(lines);
} }
@@ -1142,6 +1242,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0; velocity = 0;
pixelAccum = 0; pixelAccum = 0;
isTouching = true; isTouching = true;
touchContactId++;
didScroll = false; didScroll = false;
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
// Classifying scans the whole viewport with translateToString, and // Classifying scans the whole viewport with translateToString, and
@@ -1246,7 +1347,7 @@ Object.assign(CodemanApp.prototype, {
if (this._shouldForwardWheelToApp({ shiftKey: false })) { if (this._shouldForwardWheelToApp({ shiftKey: false })) {
this._logScrollRouting('forward-sgr'); this._logScrollRouting('forward-sgr');
this._forwardScrollToApp(touchLastX, touchLastY, lines); 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._logScrollRouting('local-scrollback');
this._noteTerminalUserScroll(lines); this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(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 * Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable. * 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 // both continuation kinds, the indent a hard wrap leaves on its
// continuation, and the offset↔cell mapping — because touch selection // continuation, and the offset↔cell mapping — because touch selection
// measures the SAME lines and the two must not disagree. // 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) // Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport. // cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12; const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalLogicalLine( const logical = window.CodemanTerminalLines?.terminalPathLine(
buffer, buffer,
bufferLineNumber - 1, bufferLineNumber - 1,
terminal.cols, terminal.cols,
@@ -1991,12 +2107,18 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 }; 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); callback(undefined);
return; return;
} }
const links = []; 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 // Pattern 0: URLs (https://, http://) — matched first so they take priority
// //
@@ -2016,6 +2138,7 @@ Object.assign(CodemanApp.prototype, {
const start = coordAt(startCol); const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length); const end = coordAt(startCol + cleaned.length);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; 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({ links.push({
text: cleaned, text: cleaned,
@@ -2045,27 +2168,28 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n). // 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; 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 // included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file // screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink). // preview rather than the log viewer (see addLink).
// //
// The literal lives in constants.js because the response viewer linkifies // The patterns live in constants.js because the response viewer linkifies
// the SAME paths out of markdown — one definition, two consumers. A fresh // the SAME paths out of markdown — one definition, two consumers.
// instance per call: `lastIndex` is per-object state.
const extPattern = absoluteFilePathPattern();
// Pattern 3: Bash() tool output // Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; 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); const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return; if (startCol === -1) return;
const start = coordAt(startCol); const start = coordAt(startCol);
const end = coordAt(startCol + filePath.length); 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 (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({ links.push({
text: filePath, text: filePath,
@@ -2088,12 +2212,29 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview // path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external // reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree. // 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(); const sessionId = getSessionId();
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) { const target = self._resolveLinkedFilePath(text, sessionId);
self.openFilePreview(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; 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() { hover() {
setHovered(true); setHovered(true);
@@ -2117,9 +2258,8 @@ Object.assign(CodemanApp.prototype, {
addLink(match[2], match.index); addLink(match[2], match.index);
} }
extPattern.lastIndex = 0; for (const found of findFilePathLinks(lineText)) {
while ((match = extPattern.exec(lineText)) !== null) { addLink(found.path, found.index, !!found.folder);
addLink(match[1], match.index);
} }
bashPattern.lastIndex = 0; 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 unbounded path. Must be called AFTER scrollLines(), since the check is on
* the resulting position, and it is deliberately not folded into * the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason. * _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) { _maybeLoadMoreHistoryOnScroll(lines) {
if (lines >= 0) return; if (lines > 0) {
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); 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 // Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers) // (from historical SSE data that was stored with markers), and every
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); // 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 // For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) { if (cleanBuffer.length <= chunkSize) {
@@ -4923,6 +5081,51 @@ Object.assign(CodemanApp.prototype, {
return Number.isInteger(columns) && columns > 0 ? columns : 0; 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, // Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN // then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined. // option serves plain HTTP, where navigator.clipboard is undefined.
@@ -5547,7 +5750,7 @@ Object.assign(CodemanApp.prototype, {
// pane; either of the first two left out means the primary pane's. // pane; either of the first two left out means the primary pane's.
// `ephemeral: true` sends it through _sendInputEphemeral instead of the // `ephemeral: true` sends it through _sendInputEphemeral instead of the
// persisted exactly-once queue: a TerminalTile's mouse reports never enter // persisted exactly-once queue: a TerminalTile's mouse reports never enter
// that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one // that queue (src/web/CLAUDE.md, Split-pane sessions), or a reload would replay one
// onto a later screen. Left out, the report stays on _sendInputAsync, as the // onto a later screen. Left out, the report stays on _sendInputAsync, as the
// primary pane always sent it. // primary pane always sent it.
_sendSyntheticSgrTap(clientX, clientY, target = {}) { _sendSyntheticSgrTap(clientX, clientY, target = {}) {
@@ -5713,35 +5916,38 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* True when this session's LOCAL scrollback is structurally empty: a pane whose * True when this session's LOCAL scrollback is structurally empty and its CLI
* TUI repaints one full screen in place, so tmux keeps no history for it * can page its own transcript: xterm's normal buffer never grows past one
* (`history_size≈0`) and xterm's normal buffer never grows past one screen * screen (`baseY === 0`), so scrolling it is a no-op no matter how the gesture
* (`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).
* is routed — the "wheel does nothing at all" half of the #205 retest.
* *
* Two shapes, measured separately: * Shapes measured so far:
* - `claude` in repaint mode (the original, #205 round 2), and * - `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 * - `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 * `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 * terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture * 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 * byte-identical — so paging is the only gesture that reaches it;
* this the wheel was silently dead in every opencode tab. * - `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 * ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour * list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
* is unverified (docs/scrollback-fix-plan.md). * literal here. A missing list means no mode pages.
* *
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which * `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
* calls this with its own session and terminal, so the mode list above stays * calls this with its own session and terminal, so the mode rule stays here
* here alone. `localRows` replaces `baseY` as the history row count: a tile * alone. `localRows` replaces `baseY` as the history row count: a tile
* discounts the stale rows its own load order leaves above the screen * discounts the stale rows its own load order leaves above the screen
* (TerminalTile._localRows). Every field left out means the primary pane's. * (TerminalTile._localRows). Every field left out means the primary pane's.
*/ */
_localScrollbackIsHollow(target = {}) { _localScrollbackIsHollow(target = {}) {
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude'; 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; const buf = (target.terminal || this.terminal)?.buffer?.active;
if (!buf || buf.type === 'alternate') return false; if (!buf || buf.type === 'alternate') return false;
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY; 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 * 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 * 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 * 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 * 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. * 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 * Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* session with real local scrollback is never touched. Shift is excluded on * `baseY === 0`) plus a false forwarding gate, so a session with real local
* purpose: it is the explicit "give me local scrollback" gesture and must keep * scrollback is never touched. Shift is excluded on purpose: it is the explicit
* that meaning. * "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 * @returns true when the gesture was consumed here (the caller must not also
* scroll locally). * 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 * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
* through the same gates and the same pageKeysForTravel arithmetic; keep the * through the same gates and the same pageKeysForGesture, with its own state
* two in step. The tile adds one gate this pane cannot need, viewport at the * and its OWN session's dialog check; keep the two in step. The tile adds one
* bottom: hollow here means baseY 0, so this viewport is always there, while a * gate this pane cannot need, viewport at the bottom: hollow here means baseY 0,
* tile is hollow with its own discounted rows still above the screen. * so this viewport is always there, while a tile is hollow with its own
* discounted rows still above the screen.
*/ */
_maybePageCliTranscript(ev, lines) { _maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false; if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
if (!this._localScrollbackIsHollow()) 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) { if (this._pageKeySession !== this.activeSessionId) {
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); const step = window.CodemanTerminalInput.pageKeysForGesture(
this._pageKeyPending = step.pending; this._pageKeyGesture,
ev,
lines,
this.terminal?.rows,
performance.now()
);
this._pageKeyGesture = step.state;
if (step.keys) this._queueScrollBytes(step.keys); if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys'); this._logScrollRouting('page-keys');
return true; return true;
+23 -8
View File
@@ -344,12 +344,14 @@ Object.assign(CodemanApp.prototype, {
const sessionId = tileAt(e.target); const sessionId = tileAt(e.target);
const files = Array.from(e.dataTransfer?.files || []); const files = Array.from(e.dataTransfer?.files || []);
if (!sessionId || files.length === 0) return; if (!sessionId || files.length === 0) return;
const images = files.filter((f) => String(f?.type || '').startsWith('image/')); // The one classifier every pick and drop filter reads (image-input.js):
if (images.length === 0) { // MIME first, then the name's extension, so documents and videos count too.
this.showToast?.('Only image files are supported', 'error'); const media = files.filter((f) => this._promptAttachKind?.(f));
if (media.length === 0) {
this.showToast?.('Unsupported file type', 'error');
return; return;
} }
this._uploadAndInsertImages?.(images, { sessionId }); this._uploadAndInsertImages?.(media, { sessionId });
}); });
}, },
@@ -2978,7 +2980,10 @@ Object.assign(CodemanApp.prototype, {
place(sessionId); place(sessionId);
if (!grid.has(sessionId)) return false; if (!grid.has(sessionId)) return false;
(this._tileDockedAt ||= new Map()).set(sessionId, Date.now()); (this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
if (poppedOut) this._postWindowMessage?.({ type: 'close-request', id: sessionId }); // 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 }); this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
return true; return true;
}, },
@@ -3002,7 +3007,9 @@ Object.assign(CodemanApp.prototype, {
*/ */
_onTileAdoptedElsewhere(sessionId) { _onTileAdoptedElsewhere(sessionId) {
const recent = this._lastTileDrag; const recent = this._lastTileDrag;
const dragging = this._tileTearOff?.id === sessionId; // 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 pending = this._tileTearOffPending?.id === sessionId;
const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS; const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
if (!dragging && !pending && !justEnded) return false; if (!dragging && !pending && !justEnded) return false;
@@ -3010,8 +3017,16 @@ Object.assign(CodemanApp.prototype, {
this._lastTileDrag = null; this._lastTileDrag = null;
const grid = this._tileGrid; const grid = this._tileGrid;
if (!grid?.has(sessionId)) return false; if (!grid?.has(sessionId)) return false;
if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false }); // One stored grid per browser, and the window that docked the tile has
this.closeTileGrid({ keepStored: false, reselect: false }); // 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; this.activeSessionId = null;
try { try {
localStorage.removeItem('codeman-active-session'); localStorage.removeItem('codeman-active-session');
+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)}" return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})" onclick="app.handleWebviewTabClick(event, ${jsonId})"
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}"> aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''} ${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
@@ -347,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
return this.openWebview(id); 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. */ /** Mark exactly one tab active across BOTH tab kinds. */
_updateActiveWebviewTab() { _updateActiveWebviewTab() {
const container = this.$('sessionTabs'); 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'); for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
} }
this._syncTabTreeSelection?.(container); this._syncTabTreeSelection?.(container);
this._renderTabFocus?.();
}, },
// ── Opening / closing ───────────────────────────────────────────────────── // ── Opening / closing ─────────────────────────────────────────────────────
+9
View File
@@ -17,6 +17,15 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
import { SseEvent } from './sse-events.js'; import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js'; import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-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 { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js'; import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js'; import { dataPath } from '../config/instance.js';
+4 -1
View File
@@ -36,6 +36,7 @@ import {
} from '../../user-store.js'; } from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js'; import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { webviewCapabilities } from '../../webview-capabilities.js'; import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import { appendAdminAudit } from '../admin-audit.js'; import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.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 // 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. // touch; a forced logout that left them alive would not be a logout.
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username)); 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 } }; return { success: true, data: { revoked } };
}); });
@@ -206,6 +208,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
} }
revokeUserSessions(ctx.authSessions, username); revokeUserSessions(ctx.authSessions, username);
webviewCapabilities.revokeOwner(normalizeUsername(username)); webviewCapabilities.revokeOwner(normalizeUsername(username));
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
if (deleteSpace) await deleteUserSpace(username); if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length }); audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {}); 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))) { if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals'); return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
} }
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to // Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
// a third-party TUI whose keystroke contract Codeman has not measured, the // dialog belongs to a third-party TUI whose keystroke contract Codeman has
// Claude-shaped option parser never reads options off its frames, and // not measured, so '1'/Esc would be a blind keystroke into a foreign
// verifyStillAnswerable() can therefore never be conclusive for it — so the // composer. The item still raises the red alert and clears on the harness's
// '1'/Esc below would be a blind keystroke into a foreign composer. The item // own working/stop reports; answering happens in the terminal.
// still raises the red alert and clears on the harness's own working/stop if (item.answerInTerminal) {
// reports; answering happens in the terminal.
if (session.mode === 'deepseek') {
return createErrorResponse( return createErrorResponse(
ApiErrorCode.INVALID_INPUT, 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.' '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). // admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
const denied = adminOnly(req, reply); const denied = adminOnly(req, reply);
if (denied) return denied; 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 // Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
+252 -34
View File
@@ -23,6 +23,7 @@ import type {
} from '../../types.js'; } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { compileFileQuery } from '../../utils/file-query.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 { fileStreamManager } from '../../file-stream-manager.js';
import { import {
AUDIO_ATTACHMENT_EXTENSIONS, AUDIO_ATTACHMENT_EXTENSIONS,
@@ -30,12 +31,21 @@ import {
attachmentRecordToEvent, attachmentRecordToEvent,
attachmentRegistry, attachmentRegistry,
buildFileThumbnailRoute, buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension, isSupportedAttachmentExtension,
registerExternalAttachment, registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS, TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS, VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord, type AttachmentRecord,
} from '../../attachment-registry.js'; } from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
folderAllowedForUser,
listFolder,
validFolderFileName,
} from '../../folder-listing.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
@@ -200,8 +210,8 @@ interface FileBodySource {
cleanup?: () => void; cleanup?: () => void;
} }
/** Byte source for a LOCAL file (the historical, only path). */ /** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
function localFileSource(resolvedPath: string) { export function localFileSource(resolvedPath: string) {
return (range?: { start: number; end: number }): FileBodySource => ({ return (range?: { start: number; end: number }): FileBodySource => ({
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined), 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 * `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, * 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. * 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, reply: FastifyReply,
size: number, size: number,
rangeHeader: string | string[] | undefined, 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 // 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. // family is servable, so widening what can be READ never widened what can RUN.
// The preview overlay reads these through `fetch()`, which ignores the // 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'; const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
if (download || markupOnly) { if (download || markupOnly) {
reply.header( reply.header(
@@ -426,6 +441,40 @@ async function resolveServableRemoteAttachment(
return { path: probe.realPath, probe }; 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 * 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 * stream it inline. PDF/PNG and text formats don't need conversion — callers
@@ -520,8 +569,20 @@ function getKnownSessionWorkingDir(
interface SessionFileScope { interface SessionFileScope {
workingDir: string; workingDir: string;
remote?: SessionRemote; 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 * The file-access scope of a session, live or persisted — the `workingDir`-only
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes * variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
@@ -539,12 +600,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req); const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId); const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) { 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); const stored = ctx.store.getSession(sessionId);
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) { 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`)); 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')); reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return; return;
} }
if (session.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try { try {
// A remote case registers a path that lives on the REMOTE host: the guard and // 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; if (!servable) return;
try { await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
// A remote record streams over ssh exactly like file-raw, with the same download,
// 200/206/416 contract, and its size comes from the guard's own re-probe — so preview,
// serving a remote attachment needs no stat the local branch would not also need. range: req.headers.range,
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)}`));
}
}); });
// Serve a converted PDF preview of a registered attachment by id. Office docs // 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); 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 // Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw. // are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => { 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 { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js'; import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.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 { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.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, toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
message: typeof safeData.message === 'string' ? safeData.message : undefined, message: typeof safeData.message === 'string' ? safeData.message : undefined,
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : 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 // 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 // session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning. // alert nobody can answer; notePrompt() carries the whole reasoning.
@@ -244,7 +249,9 @@ export function registerHookEventRoutes(
sessionId, sessionId,
sessionName, sessionName,
...safeData, ...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 }),
}); });
} }

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