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
Codeman maintainer 2c55fe907e feat(tiles): move tiles by hand, and detach a tile into its own window
Gesture control now moves tiles: with the tile grid open, pinch anywhere
on a tile and carry it onto another tile (they swap) or an empty cell (it
moves there); a session tab carried onto the grid joins it there. The
overlay asks tile-grid.js what is under the hand (tileAtPoint,
tileDropTargetAt) and drops through the mouse drag's own paths
(dropSessionOnTile / dropSessionOnSlot, so _reorderTiles stays the one
move path).

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

With the setting off nothing changes. No server change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:17:26 +02:00
Codeman maintainer 3d59e59e37 chore: version packages (1.41.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:12:42 +02:00
Codeman maintainer 019118ba9d chore: fold #581's own changeset into the 1.41.0 changeset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:12:00 +02: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 7a4d520cd0 chore: changeset entry for the Tiles open order and kept layout
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:49:39 +02:00
Codeman maintainer 08d7fa1985 Merge the Tiles open order and kept layout into the 1.41.0 release branch 2026-10-10 06:49:25 +02:00
Codeman maintainer 0c6cbd0718 docs(tiles): a popped-out session frees its place too
The wiki said a session closed in the meantime frees its place for the
ranking. A session popped out into its own window does the same (it is
never tiled while its window owns its PTY size, and since the previous fix
popping it out with the grid open keeps the count, too), so the sentence now
names both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:38:46 +02:00
Codeman maintainer 8b2cb49940 docs(tiles): a reload brings the grid back only when it was open
The wiki, decision 11 in the tile-grid plan, its Persistence section, the
layout-memory test header and two tile-grid.js comments said the Tiles
button and a page reload bring the grid back however it was closed. Only
the Tiles button does: a grid closed before the reload stays stored as
open: false, the page shows the single view, and the toggle puts the grid
back. The behaviour was right; the wording now says that.

A test pins it through the real close path: arrange the grid, close it with
the toggle, start a fresh page, and the restore leaves the single view
while the toggle still brings back the cells, count, divider sizes, focus
and zoom. ('a stored closed grid leaves the single view' in
tile-grid-restore.test.ts covers the same from a raw stored value through
handleInit.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:33:00 +02:00
Codeman maintainer fb5c1bfca5 docs(tiles): a reload's fill ranks without pending approvals, by design
A page reload puts a stored open grid back inside handleInit and fills a
cell freed since (its session deleted while the grid was closed) from the
ranking. Pending approvals, the source of the needs-input group, reach the
page only through seedApprovals, an async GET that lands after the restore,
so for that one fill a session waiting on a permission dialog or an unseen
finished turn ranks with the quiet ones (working sessions still rank first).

Holding the fill back until the seed lands would open fewer tiles, which
can be another shape, and then reshape the grid and move the user's tiles a
moment after the reload, so a late seed never re-forms a restored grid. The
plan, architecture-invariants#tile-grid and the wiki now say so, and a test
pins it: the seed arrives after the restore, the ranking then puts the
needs-input session first, and the restored cells and the stored layout
stay as they came back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:31:26 +02:00
Codeman maintainer 7b95856cc5 fix(tiles): popping out a tiled session keeps the grid's count
A session popped out while the grid was open (its pop-out button, or
another dashboard tab announcing the pop-out over the window channel) left
the grid through _markDetached, which removed its tile as if the user had
closed it: the stored count dropped to five and its cell stayed a hole the
ranking never filled, so after the window re-docked Tiles off and on showed
five tiles and an empty cell. Popping out with the grid closed, or a reload
reconcile, already kept the count and refilled the cell.

_markDetached now removes the tile as `gone` (it left by itself), the same
as a deleted session or a refused socket, so the count stays and the next
activation fills the cell from the ranking.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:30:08 +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 3a40dae1dd test(tiles): pin the reload fill to the init payload's states and tab order
A page reload restores the stored grid inside handleInit; a cell freed by a
session gone since is filled from the ranking. handleInit syncs the tab
order and loads the session states before the restore runs, so the fill
ranks with real data: this test runs the real syncSessionOrder against a
server snapshot order and shows a working session taking the freed cell
ahead of the first tab. Pending approvals are seeded asynchronously after
init, so the needs-input group cannot rank on a reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:06:22 +02:00
Codeman maintainer a81d344fb5 docs(tiles): the ranking and the kept layout
The tile-grid plan records owner decision 11 (the layout is kept per
browser and comes back exactly; a fresh or filling grid ranks working,
then needing input, then most recent) and marks where it supersedes
decision 10's "the count wins over a remembered grid's size". The plan,
architecture-invariants#tile-grid and the CLAUDE.md Tile grid paragraph
now describe the stored format (`count`, freed cells, still v: 1), every
close path keeping it, and the open order; the wiki tells users what the
Tiles button brings back and how a new grid is chosen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:01:34 +02:00
Codeman maintainer 16980d644d feat(tiles): keep the arranged layout per browser across toggles and reloads
The Tiles toggle used to fill a remembered grid to the count picked in the
menu (refilling a hole the user made, or trimming tiles), removing the last
tile or "Open group as tiles" forgot it, and Ctrl/Cmd+click repacked it.
The owner asked for the opposite: "when I turn tiles off and on, always
keep what the last setting was".

Now the layout the user arranged (which session sits in which cell, holes
included, the tile count, the divider sizes, the focused tile and a zoom
they chose) is written to localStorage on every change and comes back
exactly from the toggle, Ctrl+Shift+G and a reload, however the grid was
closed (toggle, a non-tiled tab, leaveTiles or a #session= link, Home, the
width gate, the last tile, a group, closing or killing sessions). Nothing
of it reaches the server.

- The stored value stays v: 1 and gains `count`: how many tiles the user's
  own last change left. A session that goes away by itself (deleted,
  popped out, socket refused) does not lower it, so the next activation
  fills that cell from the ranking; a hole the user made stays a hole.
- sanitizeTileGridState reports `freed` cells (sessions gone since) and
  derives `count` for values written before it; restoreTileGridCells (pure)
  puts a stored grid back, freed cells first, never trimmed to the window
  (a too-small window shows the focused tile until it fits).
- Only when none of the stored sessions survive does activation rank from
  scratch. A count picked in the menu still re-forms the grid to it.
- Nothing is persisted while a stored grid is being put back, and a
  #session= link only flips `open`, keeping gone ids for the fill.
- The hover card no longer promises "a click opens N" when a stored grid
  will open instead.

This supersedes decision 10's answer that the count wins over a
remembered grid's size; the affected tests say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:58:17 +02:00
Codeman maintainer b538429f6e chore: changeset entry for the npm README fix
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:47:25 +02:00
Codeman maintainer 7689a43782 fix(release): npmjs.com shows the English README, and the package metadata names what Codeman is
npm takes the package's readme from an unsorted {README,README.*} glob at
publish time, and with README.zh-CN.md beside README.md it kept picking the
Chinese one, so npmjs.com rendered it. `npm run release` now runs
scripts/npm-release.mjs, which moves README.zh-CN.md to a dot-name for the
length of `changeset publish` and always puts it back (also on a failed or
thrown publish, and after an interrupted earlier run). It lives in the publish
command, not in release.yml, because changesets/action commits the working
tree into its version PR. Verified with npm's own normalize: README.zh-CN.md
before, README.md with the file aside, and the aside copy is never packed.

package.json: the description names the CLIs and says self-hosted (the old
"run 20 autonomous agents" was an internal performance target), and homepage
points at getcodeman.com instead of repeating the repository link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:47:15 +02:00
Codeman maintainer 950450b137 feat(tiles): open and fill tiles by work, then input, then recency
The Tiles button with no stored grid opened the first tabs, so the oldest
sessions got tiles while the busy ones were left out. It now ranks the
open sessions: working first (the most recently started turn first, keyed
off lastSubmitAt only, since a working pane's activity stamp is always
"now"), then the ones needing input (the red and yellow tab alerts), then
everything else by most recent activity, tab order breaking ties.

The ranking is pure (rankTileSessions in constants.js) and reuses the home
screens' classification (_mobileOverviewState) and stamps. It feeds the
toggle's fresh set, a count picked in the menu, Ctrl/Cmd+click and an open
split's fill. The active session is still always included and focused,
detached sessions are never tiled, and a page without the classifier falls
back to tab order. The vm harness now loads mobile-overview.js so the app
tests exercise the real classifier.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:39:51 +02:00
Codeman maintainer 55cd59a346 chore: changeset entry for the faster session close
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:02:48 +02:00
Codeman maintainer 209a539489 Merge master into the 1.41.0 release branch (faster session close, README GIFs) 2026-10-10 05:02:29 +02:00
Codeman maintainer 71e6cfa0a2 docs(tabs): drag stays on during a rail search (#580)
Owner decision: searching for a tab and dragging it into a group is a useful
flow, so the drag-off guard is reverted and the docs and changeset say drag
works during a search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:01:34 +02:00
Codeman maintainer be9454a29b Revert "fix(tabs): no drag while a rail search is active (#580)"
This reverts commit 183efacc93.
2026-10-10 05:01:34 +02:00
Codeman maintainer 29c2701f83 Revert "fix(tabs): a found tab still drops onto the tile grid during a rail search (#580)"
This reverts commit f152d551d1.
2026-10-10 05:00:50 +02:00
Codeman maintainer f152d551d1 fix(tabs): a found tab still drops onto the tile grid during a rail search (#580)
183efacc turned drag off while the rail search narrows the list, as the
owner asked, by refusing the flat manual rail's dragstart. That also
refused a drag that never reorders anything: with the tile grid open, a
found tab dragged onto a tile or an empty cell set no draggedTabId, so
_acceptTabDrops() (tile-grid.js) saw nothing and the drop was lost. On
the PR head that drop replaced or swapped the tile. The owner's reason
covers the reorder only: a reorder drop is saved for every device,
relative to rows the search hides, while the tile grid is per-device and
lands beside nothing hidden.

dragstart is unconditional again. The rows refuse the reorder instead:
their dragover returns before preventDefault while _tabRailSearchActive()
(the browser shows no-drop and fires no drop there), and their drop
returns before touching sessionOrder, for anything above the row that
might let a drop through. `draggable` stays on, so a cleared search
reorders with the same rows. The grouped rail's pointer drag keeps its
refusal in _onTabLayoutPointerDown(): it has no drop target besides the
rail.

Test: during a search that leaves both rows showing, dragstart is not
cancelled and sets draggedTabId, dragover on another row is not
accepted, a drop there leaves sessionOrder alone and saves nothing, the
real _acceptTabDrops() binder accepts the drag on a tile and hands it
the id, and after clearing the same rows reorder. It fails on the
previous commit, without the dragover guard, and without the drop guard.
CLAUDE.md, the invariants, the Dashboard wiki row and the release
changeset now say reordering by drag is off and a tile drop still works.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:45:35 +02:00
Codeman maintainer 6844cccd18 fix(tabs): no connector from a parent row the search hid (#580)
A floating subagent or ultracode window whose parent session's row the
rail search hid drew its connector from the viewport's top-left corner.
The hidden row is display:none, and getBoundingClientRect() still
answers it with an all-zero DOMRect, which is truthy, so the
`if (!tabRect) continue` paths never skipped it and _tabAnchor() put the
line at (0, 0) ("M 0 0 C 350 0, 350 400, 700 400" in Chromium). The
sidebar filter already had the same defect; #580 brought it to the rail,
and the keystroke redraw from 5f373a93 now ran it on every keystroke that
hides the parent.

A new _paintedSessionTab() in app.js returns the parent's row only while
getClientRects() is non-empty, and every place a floating window
measures its parent tab goes through it:

- the shared `tab:<id>` rect cache, filled by the subagent connectors
  and by both ultracode connector layers (all three fill it, so guarding
  only the first would let the next one cache the zero rect itself);
- the ultracode window's spawn position, which now cascades;
- the subagent window's fly-from-tab spawn, the same defect, not in the
  report, now positioned as a window without a tab;
- the ultracode genie on minimize, which now tears down at once.

Lineage lines need nothing: they measure on a cache miss and computeTree
already drops zero-size rects.

Tests: in the gate, with the page laid out by hand (jsdom has no
layout), a subagent window, an ultracode run window and an ultracode
agent window from a hidden row draw no line and draw it again from the
row once the search is cleared, an ultracode window spawns from a
painted row and cascades from a hidden one, and the genie is skipped for
a hidden row. In the browser suite, real Chromium shows the hidden row's
rect is all zero and the connector is gone, then back after Clear. All
four fail on the previous commit. CLAUDE.md and the invariants'
Connectors bullet record the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:37:49 +02:00
Codeman maintainer ac8e0bdcbb chore: changeset entry for #580
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:15:26 +02:00
Codeman maintainer 9d6c010819 docs: record the rail search in the architecture invariants (#580)
CLAUDE.md gained a rule for the rail's Search sessions box and linked it
to architecture-invariants#session-list-layout-header-strip-vs-left-sidebar,
which said nothing about it, and the "Collapse is per-device" bullet under
Owner tab layouts had an exception it did not record.

- Session list layout: one paragraph on the shared _applyTabListFilter()
  over the pure CodemanTabSearch (classes only, layout-scoped hide rules,
  rail matches the name and the sidebar name + folder, locale-independent
  lower-casing), the alert-row keep (owner decision), the data-total count
  restore, the tree walk and roving-stop fix-up, the projection opening
  every group, the connector redraw, the global Escape claim, no drag
  while searching, and the reset off the rail.
- Owner tab layouts: the collapse bullet notes that a search draws every
  group open and refuses toggles without writing the stored set.
- CLAUDE.md: the Escape claim and the connector redraw as one clause on
  the existing rail search rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:11:00 +02:00
Codeman maintainer 183efacc93 fix(tabs): no drag while a rail search is active (#580)
From the owner's review of #580 rather than the bot's report: "Drag
while searching: I'd turn it off. A drop is saved for every device
(session order or the tab layout), and where it lands relative to the
rows the search is hiding is something you only see after clearing it."
The PR head left drag on.

Both rail drags now refuse while the search narrows the list: the
grouped rail's pointer drag in _onTabLayoutPointerDown(), and the flat
manual rail's HTML5 drag in its dragstart listener. The flat rail is
refused in the listener, not by flipping `draggable`, because a
keystroke in the box does not re-render the rows, so a cleared search
drags again with the same rows. The sidebar filter box, the header strip
and the keyboard moves (Ctrl+Shift+{ }, the row menu) are unchanged.

Tests: a grouped-rail press during a search starts no drag and one after
clearing does; a flat-rail dragstart during a search is refused and one
after clearing goes through. CLAUDE.md and the Dashboard wiki row say
so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:10:05 +02:00
Codeman maintainer 4a94eb9ba7 fix(tabs): lower-case search text without the browser locale (#580)
CodemanTabSearch lower-cased the query and each row with
toLocaleLowerCase(), i.e. the browser's locale. Under a Turkish or Azeri
locale "API Review" lowers to "apı review", so a search for "api" missed
it, and the sidebar filter box, which used locale-independent
toLowerCase() before #580, changed matching with it. Both now use
toLowerCase(), like every other frontend search.

Test: a vm context of its own whose toLocaleLowerCase behaves like the
Turkish locale (the prototype is that context's alone) checks that "API"
still normalizes to "api" and that "API Review" matches. The test file's
overview also lists the pins the #580 fixes added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:08:32 +02:00
Codeman maintainer e589004926 fix(tabs): hide a case box the sidebar filter emptied (#580)
Since the sidebar filter and the rail search share _applyTabListFilter(),
the sidebar's filter box also marks a .tab-cluster it emptied in the
by-case tab layout and rewrites its count. But the only rule hiding
.tab-cluster.tab-filtered-out was the rail-scoped one, so in the sidebar
the emptied box stayed painted with its label and a count of 0 while its
rows were hidden.

The sidebar rule now hides the marked case box as well. It stays scoped
to html[data-session-list="sidebar"], so a leaked class still cannot hide
anything on the header strip. An alerted row still counts toward its box
(the owner's call on #580), so a box holding a row that needs the user
never hides.

Tests: a unit test renders the sidebar in the by-case layout and checks
the emptied box is marked with a 0 count, that an alerted row keeps its
box on screen and counted, and that clearing restores the totals; a
stylesheet check pins the scoped selector; the browser test measures the
shipped styles.css in Chromium: the marked box is not painted in the
sidebar layout, an unmarked one is, and the header layout hides nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:08:07 +02:00
Codeman maintainer 5f373a9324 fix(tabs): redraw connector lines when a search moves the rows (#580)
A keystroke in the rail search box that does not change the group
structure only toggles classes, so hidden rows collapsed and the rows
below them moved up while the lineage lines and the subagent/ultracode
connectors kept pointing at the old positions until the next render. The
sidebar filter box had the same gap for its connectors.

_applyTabListFilter() now notes whether anything it touched actually
appeared or disappeared (a row, a group or case box, the state headings
via tabs-filtering, the "No sessions match" line) and calls
updateConnectionLines() only then. That covers both boxes and the alert
re-render, costs nothing on the re-apply every render tail runs when
nothing changed (the incremental path's lineage gate keeps its meaning),
and coalesces with a render's own redraw.

Same flag, owner's optional nit from the review: the grouped tree's
posinset/roving-stop fix-up at the filter tail now runs only while a
search hides something or right after one changed what shows, since both
render paths already set them over an unfiltered tree.

Tests: unit tests for the rail keystroke path (flat and grouped rail, no
render taken), the empty note, the sidebar box, and no redraw when
nothing moved; the browser test checks in Chromium that a class-only
keystroke moves a row up and redraws exactly once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:06:50 +02:00
Codeman maintainer ac5f2aa3f4 fix(tabs): Escape in the rail search box clears only the search (#580)
The global key handler in setupEventListeners() sits on document in the
capture phase, so it ran before the box's inline onkeydown: the Escape that
cleared the search also ran closeAllPanels() (collapsing the Monitor and
Subagents panels), closeHelp() and closeSessionManager(). Calling
stopPropagation() from the inline handler came too late.

The global Escape branch now claims an Escape whose target is
#tabRailSearch while the box holds text, the same way it already claims
one for the group menu, the grouped-rail drag and the Tiles count menu,
and routes it to handleTabRailSearchKeydown(). An empty box still leaves
Escape to the global handler, and an Escape that cancels an IME
composition is left to the IME.

Tests: the unit test installs the real global listener and asserts that
Escape with text in the box closes nothing, that an empty box closes as
before, and that an Escape outside the box is not claimed. The browser
test installs setupEventListeners() for real in Chromium, so the
capture-before-inline ordering is the shipped one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:05:57 +02:00
Codeman maintainer 2881ef5fa5 Merge pull request #580: feat(tabs): search sessions by name on the vertical rail 2026-10-10 03:54:39 +02:00
Codeman maintainer 310a4c2489 chore: changeset entry for #581
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:41:38 +02:00
Codeman maintainer 29d8ca16b4 fix(mcp-sync): route tests never follow COPILOT_HOME, and docs name Copilot (#581)
The route test cleared only the registry CLIs' relocation vars, so with
COPILOT_HOME exported it wrote its fixture into that real Copilot config.
It now clears the sync-only tools' vars too, and Copilot's install probe goes
through the test's own installed set instead of the machine's PATH.

Docs: CLAUDE.md, the API reference and the Settings reference name COPILOT_HOME
and the sync-only table; a missing comma in docs/cli-registry.md; the
mcp-sync.ts overview and the Sync confirm mention Copilot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:41:38 +02:00
Codeman maintainer 92e54163e0 Merge pull request #581: feat(mcp-sync): sync GitHub Copilot CLI's MCP servers too
Conflict in docs/wiki/Settings-Reference.md resolved by keeping #565's Apply
wording and adding Copilot (and COPILOT_HOME, which the bot's review found
missing from this list).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:40:20 +02:00
Codeman maintainer 39a91a0c7f chore: changeset entry for #574
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:39:45 +02:00
Codeman maintainer cea199651a Merge pull request #574: feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder
Conflicts resolved against the release branch: docs/api-reference.md keeps
both new sections (codeman agent CLI, then Prompt uploads);
test/test-ports-guard.test.ts takes the release side (#570 already removed
the legacy list); test/paste-image-dir-shared.test.ts keeps #570's ephemeral
port and this PR's bounded-path-probe mock.

Also at merge: the Docker sentence in the route comment and the API reference
is narrowed to owned cases, since an adopted container mounts nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:39:33 +02:00
Codeman maintainer 656ab98934 chore: changeset accuracy (notification time range, landing fixes)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:37:24 +02:00
Codeman maintainer e6382ecfc0 fix(i18n): zh-CN for the Animations settings section, and tab names are never translated (#571)
The new Animations section (rail item, heading, Entrance Theme and Tile
Animations rows with their options, the lab row) had no zh-CN entries.

Tab names were not in SKIP_SELECTOR (the list names .session-tab-name, which
the strip does not use), so a session called "Lab", "Soft" or "New session"
rendered translated. .tab-name is skipped now, like every other user-text
surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:37:24 +02:00
Codeman maintainer 4d6d187883 Merge master into the 1.41.0 release branch (README and dashboard tour docs) 2026-10-10 03:34:58 +02:00
Aamer Akhter d3fc07ee28 feat(tabs): keep alerted tabs visible during a rail search
A session with a tab alert (red action or yellow idle, whatever tabAlerts
holds, the same set a collapsed group header surfaces) now stays visible
while the rail search or the sidebar filter is narrowing the list, even
when its name does not match. A prompt waiting on you should never be
hidden by a view filter.

The pure CodemanTabSearch.filter decides it: a row passed with keep: true
is never hidden. It counts toward its group, so the group stays on screen
and the header number is the rows left showing, but not toward
matchCount, so "No sessions match" still shows above a lone alerted row.
_applyTabListFilter() flags session rows from tabAlerts; web tabs carry
no alerts and are never kept.

No new wiring: updateTabAlertFromHooks() and _onSessionWorking() already
call renderSessionTabs(), and both render paths end in the shared filter.
2026-10-09 21:30:49 -04:00
Codeman maintainer b2de04c730 chore: the 1.41.0 release notes use the CRT clip with live header stats
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:15:49 +02:00
Codeman maintainer eada647d7b chore: changeset wording for #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:07:46 +02:00
Codeman maintainer 233d5f25c4 docs(echo): the package README describes the new scroll rule (#576)
The overlay no longer hides for any scrolled-up viewport, only when the
cursor row leaves the screen. The changeset gains the echo package's patch
bump and the #576 entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:07:34 +02:00
Codeman maintainer cf9022ac9a Merge pull request #576: fix(mobile): local echo keeps painting when the viewport is parked above the bottom 2026-10-10 03:07:18 +02:00
Codeman maintainer 87f1c9ccd4 chore: one 1.41.0 changeset, with the contributor changesets folded in
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:07:18 +02:00
Codeman maintainer d8c23210bb fix(notifications): zh-CN for the display-time settings, and review leftovers (#564)
- zh-CN entries for the two new rows (label and description).
- The toast test header still described the dropped drawer logging, and the
  noise test typed three members nothing uses any more.
- clickNotification's closing brace had been re-indented by accident.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:55:49 +02:00
Aamer Akhter 7c3877016b feat(tabs): search sessions by name on the vertical rail
A "Search sessions" box at the top of the vertical tab rail narrows the
list to the tabs whose name matches (case-insensitive substring; a web
tab matches by its title). It searches every group, collapsed ones
included: while a search runs the projection draws every group open and
the header will not toggle, and the stored per-device collapse state is
left alone. Groups with no match hide, an empty result says "No sessions
match", and the flat rail (no groups) filters the same way. Escape or
the clear button empties it; leaving the vertical orientation resets it.

It is a view filter only: rows get the same tab-filtered-out class the
sidebar filter box uses, through one shared _applyTabListFilter() over
the pure CodemanTabSearch matcher in constants.js. Grouping, order,
Alt+N badges and drag are untouched, nothing is persisted or sent to
the server. The sidebar keeps matching name plus working directory.

In the grouped tree, hidden rows and the headers of emptied groups leave
the roving walk and posinset/setsize, and the tab stop moves onto a
visible item. zh-CN strings added.
2026-10-09 20:55:32 -04:00
Codeman maintainer 7c4ad22f9c test(settings): the Apply test header names what Apply refreshes (#565)
Custom model endpoints were never gated on the saved flag; the PR's changeset
and JSDoc were narrowed in review, the test header was not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:55:01 +02:00
Codeman maintainer dd7a5b9275 docs(cli): CLAUDE.md names the 8-character id floor of codeman agent (#557)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:54:51 +02:00
Codeman maintainer 217c90b6a1 docs(tests): finish the ephemeral-port wording (#570)
- docs/browser-testing-guide.md still described the shrink-only legacy list
  and the mobile suite's fixed ports; both are gone.
- CLAUDE.md: name the one fixed port left (codex-predictive-echo's separate
  lab server on 3222), keep "Never 3000", and say the guard refuses a fixed
  port rather than that it checks boundPort is read.
- The tui-client comment described the old "port + 1" dead port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:54:39 +02:00
Codeman maintainer 93642fa616 test(viewer): the markdown anchor browser test binds an ephemeral port (#563)
The port guard refuses a WebServer built on a fixed port. It now uses port 0
and reads server.boundPort, like every other test server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:54:01 +02:00
Codeman maintainer 6db526dbad Merge pull request #562: fix(cli-install): install npm CLIs to ~/.local when the npm global prefix is not writable 2026-10-10 02:53:29 +02:00
Codeman maintainer e6deab98a6 Merge pull request #563: fix(viewer): make in-document links in rendered markdown scroll to their heading 2026-10-10 02:53:29 +02:00
Codeman maintainer 5e0cea6ec8 Merge pull request #565: feat(settings): add an Apply button that saves without closing 2026-10-10 02:53:28 +02:00
Codeman maintainer 2cc6ea1cfd Merge pull request #557: feat(cli): codeman agent — session-to-session verbs for every CLI mode (#445, phase 1) 2026-10-10 02:53:28 +02:00
Codeman maintainer c5ddb77adc Merge pull request #570: test: bind every test server to an ephemeral port (#440, 2/2) 2026-10-10 02:53:27 +02:00
Codeman maintainer d2d2ba3e4a Merge pull request #569: feat(tabs): a per-device switch to hide the CLI logos on tabs 2026-10-10 02:53:27 +02:00
Codeman maintainer 33ad314bea Merge pull request #578: fix(uploads): show the server's reason when a prompt upload fails 2026-10-10 02:53:26 +02:00
Codeman maintainer 3f4af2aa2e Merge pull request #577: fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history 2026-10-10 02:53:26 +02:00
Codeman maintainer cf11a253d3 Merge pull request #571: feat(tiles): Tile Animations setting, entrance styles for the tile grid 2026-10-10 02:53:25 +02:00
Codeman maintainer 17824f1847 Merge pull request #572: fix(git): pin git's locale for clone, so failures classify on non-English hosts (#568) 2026-10-10 02:53:25 +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
Codeman maintainer 53f61ca539 fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to
xterm for a session running Claude's fullscreen renderer (claude 2.1.187+
with mouse tracking on, cliMouseTracking). That renderer scrolls its own
transcript on SGR wheel reports, which the primary pane sends it, while the
tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no
history for such a pane). So in a grid of fullscreen Claude sessions the
wheel either scrolled nothing or dragged stale frames, Claude's pinned input
box with them, up the tile, and Claude's transcript never moved. This was
tile-grid-plan follow-up 4.

The tile now forwards the wheel the way the primary pane does
(TerminalTile._maybeForwardWheelToCli): the primary pane's own gate,
_shouldForwardWheelToApp(ev, target), asked for the tile's terminal and
session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up
viewport snapped to the live screen first; and the reports flushed through
the tile's own 40 ms coalescer to the tile's session (the primary queue
flushes to the active session). The encoding moved into pure helpers in
terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and
sgrWheelReports, which the primary pane's _wheelScrollLines and
_sendSyntheticSgrWheel now call too, so the two panes send identical bytes.

Shift+wheel, the explicit local-scrollback gesture, was dead in every tile
off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and
xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The
tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the
dominant axis, sub-line travel carried over, a shell tile's history pull
still asked on the way up), as the primary pane's capture-phase handler does.

Unchanged: inline Claude, opencode and older Claude still take the
PageUp/PageDown route (#555), shells and other modes keep xterm's own plain
wheel, and a tracking xterm or the alternate buffer stays xterm's.

Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick
cap, coalescing, viewport snap, the tile's session rather than the active
one, Shift/tracking/alternate exclusions, byte equality with the primary
pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history
pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case
with trusted page.mouse.wheel() events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:25:54 +02: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
JD 3be03c6896 fix(uploads): show the server's reason when a prompt upload fails
A failed upload only showed a count, so a remote session's refused upload read as a mystery instead of a rule the user could act on. The server already returns the reason in its error envelope and the per-file error carries it, so I surface the first one in the toast. One reason is enough when a whole batch fails for the same cause.
2026-10-09 20:13:58 -04:00
JD ba36cc36d4 fix(uploads): list upload dirs bounded and async, and keep uploads inside the data dir collected
The hourly sweep called lstatSync and realpathSync on every live session's working directory, so a linked case on a mount that stopped answering blocked the event loop 30 s after boot and then every hour; the old sweep was fully async. uploadDirs() now probes the workspace with probePathKind() first and skips an unknown path without touching it, then uses fs.promises for the lstat and realpath. The sweep keeps the probe's stall cap; cleanupSession(), acting on one path at the user's request, passes pastCap and removes the directories with fs.rm.

Refusing an upload dir that sits strictly inside the data dir protected nothing (it only ever holds uploads, and a link is already excluded by lstat) while the route kept writing there, so uploads under a workspace like the ~/.codeman/app that install.sh clones were never swept and never removed. Only the two cases that matter stay refused: the upload dir being the data dir, or containing it.

Also: the ignore file's take-back after a failed write no longer replaces the error that caused it with its own, and the shared-dir test's header no longer names the fixed port it stopped using.
2026-10-09 20:05:21 -04:00
JD 7aecbd29df fix(mobile): local echo keeps painting when the viewport is parked above the bottom
Anything typed on a phone vanished whenever the terminal viewport was not scrolled fully to the bottom. Codeman parks the viewport a few rows above the bottom on purpose (scrollToLastNonEmptyLine after a tab switch, or after the keyboard drops and the terminal regrows, so trailing blank rows stay off screen), and the overlay hid itself on a bare viewportY !== baseY test even though the prompt and the cursor were on screen. The text was buffered the whole time and Enter still sent it, which is what made the keyboard look dead.

The overlay now gates on whether the cursor row is inside the viewport (promptRowInViewport), in both the render path and the scroll handler, so a deep scroll into history still hides it. A buffer without cursorY keeps the old bottom-only rule.

Two of Codeman's custom prompt finders made the matching mistake of treating cursorY, which xterm reports relative to baseY, as a screen row; with the gate relaxed that would have painted the text on the wrong line. Both go through cursorViewportRow now, and the Claude finder looks for its glyph from the cursor's screen row up to the top of the live screen only, since a parked viewport shows scrolled-off history whose old composer rows used to be unreachable and would anchor the overlay on the wrong line.
2026-10-09 19:11:22 -04:00
JD f12b5ac88b feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder
A pasted image landed in <workspace>/.claude-images/, a name that belongs to another tool, in a folder nothing ignored, so it showed up in git status of every case that ever received a paste. Uploads now go to a flat <workspace>/.codeman-uploads/ that carries a .gitignore of `*` (written once, never over a file already there): in the workspace because that is the only path identical for a local agent and a container, flat because a nested .codeman/ is the data dir itself when the workspace is the home directory.

The directory names live in paste-image-gc.ts alone. The old folder receives nothing but stays readable for one release: the hourly sweep and the delete cleanup go through uploadDirs(), the image watcher's ignore filter reads the names. uploadDirs() lists only real directories, none that is, contains or sits inside the data dir, and nothing for a remote session, since both consumers delete. The route refuses a remote (SSH) session before touching disk: the file would land on this host under the remote path, where the agent cannot read it.

test/paste-image-dir-shared.test.ts binds port 0 and leaves the port guard's legacy list.

Decided in #553.
2026-10-09 14:10:54 -04:00
Codeman maintainer 5206a044bb feat(settings): an Animations section in App Settings
Every animation setting now has its own App Settings section, right after
Appearance (owner: easier to find). It holds the Entrance Theme (the former
Entrance Animations row), Tile Animations, and an Open lab button that closes
settings and opens the per-surface lab (?animlab=1). Appearance keeps the skin,
identity and tab settings. New animation settings go in this section;
test/app-settings-structure.test.ts pins it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 18:22:14 +02:00
Codeman maintainer abc8598100 test(git): pin gitNonInteractiveEnv()'s locale so a revert fails in CI
CI runs in an English locale, where git already answers in English, so the
real-git clone tests stay green even if the LC_ALL/LANG pin from the previous
commit is dropped. A pure assertion on the env is the only check that fails
then. It also covers LANGUAGE, which gettext ignores once LC_ALL is C.

Refs #568

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:50:07 +02:00
RandalixandClaude Opus 5.5 6ba311f38d fix(git): pin git's locale for clone, so failures classify on any host
classifyGitFailure() matches git's English stderr, but cloneRepository()
spawned git with gitNonInteractiveEnv(), which left the host's locale in
place. Under de_DE.UTF-8 a missing ref came back as FAILED / 422 instead of
REF_NOT_FOUND / 400, and a missing repository as 422 instead of 404; the
clone dialog showed "git failed: Schwerwiegend: …". gitNonInteractiveEnv()
now sets LC_ALL=C and LANG=C, as git-workspace-status.ts already does.

Two tests depended on the locale on their own:
- git-status-routes: the spy runner called git without the production
  runner's LC_ALL=C, so "Kein Git-Repository" was not recognised.
- custom-model-run-menu-ui: the modal formats with toLocaleString(), the
  test hard-coded 16,384 and 40,000.

Full suite under de_DE.UTF-8: 5 failed before, 0 after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:48:28 +02:00
Codeman maintainer decd263b17 feat(tiles): Tile Animations setting, entrance styles for the tile grid
Tiles become the fifth entrance surface (data-tile-anim), switched on in App
Settings > Appearance > Tile Animations and OFF by default: the default is the
grid's own quick fade (`settle`), exactly as before.

A styled tile plays in two beats: its frame enters as it mounts (fly out of
its session tab, dealt from the Tiles button, CRT power-on, beam down from its
tab, cascade, pop, soft), and its screen then plays the terminal pane style of
the entrance theme when its first capture lands, through the same
html[data-term-anim] rules on .tile-body. Each style has its own exit when the
Tiles button closes the grid (back into the tabs, a CRT switch-off, ...).

Picking an Entrance Animations theme presets the tile style (new Launch theme:
tiles fly from the tabs); the theme readout ignores the tile row, so changing
it never shows "Custom". Frames move transform and opacity only (one fit and
one PTY resize per tile), a reload's restore always settles, and nothing moves
under reduced motion. The lab (?animlab=1) gets a Tile grid group, a cascade
order picker and in-place replay / close + reopen.

Also removes the terminal pane's `boot` entrance style (owner decision); a
saved `boot` falls back to off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:43:55 +02:00
Codeman maintainer 82aeeaec02 docs: CLI Logos on Tabs in the settings reference and the dashboard page
The Settings Reference gets the new row in the Appearance tab table, the
Dashboard's Session tabs section says what the logo is and where to turn
it off, and the CliEntry.shortBadge comment in docs/cli-registry.md no
longer implies every tab always shows a logo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:34:09 +02:00
Codeman maintainer ad57394618 feat(tabs): a per-device switch to hide the CLI logos on tabs
Since 1.40.0 every agent tab draws its CLI logo through the run-mode-dot
slot, and there was no way to turn that off. App Settings → Appearance →
Tabs now has "CLI Logos on Tabs" (showTabCliLogos), right after Tall Tabs.

- Modelled on tabTwoRows: a per-device display key in the server-settings
  merge and an optional boolean in the .strict() SettingsUpdateSchema,
  loaded and saved by the id appSettingsShowTabCliLogos. Default ON on
  every device; only an explicit false turns it off (tabCliLogosEnabled).
- CSS only, no tab re-render: applyTabOrientation() and the pre-paint
  script in index.html stamp html[data-tab-logos="on"|"off"] from the
  same stored blob (so a reload never flashes the logos), and one rule in
  styles.css hides .session-tab .tab-harness and .home-sessions-harness.
  The rows space their children with flex gap, so nothing is left behind.
- A live flip resizes every agent tab with no render behind it, so
  applyTabOrientation() then re-takes the strip's one-row wrap decision
  (updateTabOverflowMode) and re-anchors the lines drawn from tab rects;
  a header that changes height reaches the PTY through the terminal
  container's ResizeObserver, as any header change does.
- Covered: the header strip, vertical rail, sidebar, grouped rail, ledger
  and case clusters and phone chips (all render the same tab markup) and
  the desktop home rail. Untouched: tile and split headers, the Run menus,
  the welcome launchers. The phone overview rows, the command palette and
  the tab action menu draw no logo. The shell's SH pill and the status dot
  stay.
- zh-CN for the label and the description.

test/tab-cli-logos-setting.test.ts drives the real openAppSettings() and
saveAppSettings() in JSDOM (the saved PUT body must pass the schema), the
server-settings merge, the defaults on desktop and phone, the live stamp
and its re-measure without a re-render, the real pre-paint script (on,
off, the phone key, the catch fallback), the CSS rule's exact selectors,
and the translations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:34:09 +02:00
RandalixandClaude Opus 5.5 fda1897109 test(guard): no legacy list left; flag raw listeners on a fixed port (#440)
With the sweep done, LEGACY_FIXED_PORT_FILES and its staleness test go. A second rule flags a raw listen(<number or …PORT>) and a port: with a number or …PORT constant inside listen({ … }) or new WebSocketServer({ … }); a socket path and a lower-case variable pass. CLAUDE.md, AGENTS.md, CONTRIBUTING.md and the wiki's Contributing page lose the mobile exception, and CLAUDE.md names closedPort() instead of port + 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:02 +02:00
RandalixandClaude Opus 5.5 eceaecf076 test: raw test servers listen on port 0 (#440)
http/Fastify servers in daemon-control, deepseek-status-shim, session-input-wait and the three tui tests listen on 0 and read address().port. The two "nothing listens here" ports (a fixed 3243, and the server's port + 1) come from closedPort(): bind 0, read the port, close.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:01 +02:00
RandalixandClaude Opus 5.5 6f87186deb test(mobile): createTestServer() binds an ephemeral port (#440)
The helper cached servers by port, but every mobile file starts exactly one, so the cache was never shared: it is now a Set that stopAllTestServers() walks. PORTS leaves helpers/constants.ts, the nine tests set baseUrl after start(), and the README drops the port column and the pick-a-port step.

Committed without the pre-commit hook: accessibility, subagent-windows and visual-regression were not prettier-clean on master already; formatting them would bury the port lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:01 +02:00
RandalixandClaude Opus 5.5 aa4217b8b1 test: build every in-process WebServer on port 0 (#440)
The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:28:26 +02:00
Devvyn c75d62ce04 fix(settings): Apply cannot keep a later Save open, refreshes only after a saved PUT (review)
- Split the one-shot keep-open intent from the in-flight guard and consume it before the
  first await, so a Save clicked while an Apply is in flight closes the modal.
- Refresh the dependent groups only when the settings PUT returned ok (_apiPut answers null
  or a non-ok response instead of throwing), and also when only the webhook save failed.
- Find the 'Apply or Save' hint by a data marker, not its English text; add zh-CN strings
  for the new toast and title.
- Tests run the real saveAppSettings() (Apply then Save mid-flight, a failed PUT, a webhook-only
  failure, a plain Save).
- Narrow the changeset and JSDoc to MCP sync and CLI management; touch the tray comment, the
  architecture note and the wiki.
2026-10-09 19:46:09 +08:00
Devvyn c2e55fc210 fix(cli-install): address review: drop every npm_config_prefix spelling, probe async
- Delete every spelling of npm_config_prefix before setting NPM_CONFIG_PREFIX, in the Compose
  branch too: npm run exports the lowercase key and a sorting /bin/sh let it win. The
  operator guard stays on the uppercase key only.
- A prefix that does not exist yet is judged by its nearest existing ancestor.
- The probe is async (promisified execFile, fs.promises.access, SIGKILL on timeout), awaited
  before the spawn and only for commands that run npm.
- Tests: lowercase/mixed-case keys, and the decision logic against a fake npm on PATH
  (writable, read-only, not yet created, npm missing). Docs: one clause in cli-registry.md.
2026-10-09 19:42:06 +08:00
RandalixandClaude Opus 5.5 24e51a3b8e fix(cli): review — 8-char id floor, marker echo, wake answers on send
From the review of #557:

- An id shorter than 8 characters refuses with exit 4 before any request,
  on every verb. `rm 9` resolved to whichever session was alone with that
  first character (the user's own tab included) and deleted it. Same floor
  as the server's PARENT_SESSION_ID_MIN_PREFIX. `rm` no longer claims a
  lineage check: "Delete any session except this one".
- `wait --match` help and the README example say the marker must not appear
  verbatim in the prompt (its echo matches at once) and show the split form.
- `send` reads the route's wake-on-LAN answers: `buffered` gets its own
  line (exit 0), `dropped` exits 1 instead of printing "accepted".
- `--` for a prompt that starts with "-", in the `send` description and in
  the one-argument refusal.
- `stripAnsi` builds on the shared one (OSC sequences go too); the
  inputRefusal JSDoc sits above inputRefusal again.
- docs/wiki/Driving-Codeman-From-An-Agent.md gets a `codeman agent` section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:59:56 +02:00
RandalixandClaude Opus 5.5 0481db569d feat(cli): add codeman agent — session-to-session verbs for every CLI mode
The agent skill teaches the session verbs to claude only (Codeman seeds its
preamble for local claude sessions). An opencode, codex, pi or gemini agent has
the same environment — CODEMAN_MUX, CODEMAN_SESSION_ID and CODEMAN_API_URL are
exported into every pane — and nothing that teaches it the verbs, so
`codeman agent ls|spawn|send|wait|read|interrupt|rm` packages them as commands.

It is a client of the server, like `codeman tui`, and stays out of
`codeman session` (which drives the in-process SessionManager). No new route and
no second transport: everything goes through CODEMAN_API_URL, so auth,
ownership and the per-session waiter cap apply unchanged. Credentials and the
Basic header come from src/codeman-credentials.ts, in the same order attach and
the TUI use.

Invariants, each in test/cli-agent.test.ts:
- Refuses outside a Codeman session (CODEMAN_MUX=1 + CODEMAN_API_URL); never
  guesses a URL.
- `send` takes the prompt as ONE argument (an unquoted multi-line `$(…)` would
  otherwise be split by the shell and re-joined into one line), transmits
  printable text plus Enter only, and refuses multi-line input loudly instead of
  letting sendInput weld the lines. No resend loop of its own: the server's
  SubmitVerifier owns the swallowed-Enter case. ESC exists only as `interrupt`,
  which never appends Enter.
- `rm` fails closed: empty id, an unprovable self id, or a prefix match in
  either direction refuses.
- Nothing mode-shaped in the CLI: the readiness mark `spawn` waits for comes
  from the registry (new `capabilities.composerReadyMark`: claude's composer
  hint `shift+tab`, deepseek's `❯`), `read` relies on the route's own answer
  dispatch, and a `stop`/`blocked` the session cannot fire is the server's 400,
  passed through. A `/wait` timeout is a 200 with `timedOut`: exit 2 with a
  neutral line, not an error. A worker that dies during spawn's readiness wait
  is exit 3, like every other wait.
- `X-Codeman-Agent-Origin` rides only spawn's quick-start, the one request that
  may create a case directory; every other verb leaves it off. Server-side,
  test/routes/agent-case-marker-routes.test.ts pins that a POST /api/sessions on
  an existing workingDir is never labelled, header or not, and that quick-start
  labels only a directory it creates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:59:56 +02:00
RandalixandClaude Opus 5.5 6d862d5323 refactor(cli): one credential reader for every client of the API
`readCodemanEnv()` in cli.ts and `parseEnvFile()`/`readCodemanCredentials()` in
tui-client.ts were two copies of the same `.env` reader (the TUI's said so in a
comment), and `codeman agent` was about to need a third. They move into
`src/codeman-credentials.ts`; `codeman attach` and the TUI read from it, and the
TUI keeps re-exporting its names so nothing that imports them changes.

The lookup order is one pure function, `credentialsFrom(env, fileEnv)`: each
field from the environment, then the file, username defaulting to `admin` — the
order attach and the TUI already used. `readCodemanCredentials` takes the
environment as a parameter so a caller with its own (the agent CLI's guard) gets
the same answer. The parser now also tolerates an `export ` prefix, which the
agent skill's preamble already accepted in the same file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:59:48 +02:00
Devvyn 57d5c7b1c2 feat(mcp-sync): sync GitHub Copilot CLI's MCP servers too
Copilot CLI keeps its user MCP list in ~/.copilot/mcp-config.json (COPILOT_HOME moves it) but is
not a Codeman run mode, so it has no registry entry. Add the copilot-json dialect (mcpServers,
tools ["*"], type local/http/sse, checked against `copilot mcp add` 1.0.94) and declare Copilot as
a sync-only target in src/mcp-sync-targets.ts, listed after the registry CLIs. A server switched
off with `copilot mcp disable` is recorded in settings.json (disabledMcpServers), not on the
entry: sync reads that list so it is not copied, and reports the target unreadable if the file
is not valid JSON instead of guessing.
2026-10-09 18:42:00 +08:00
Devvyn 9c63c41283 feat(settings): add an Apply button that saves without closing
Switches such as MCP server sync only unlock their controls once saved, and
Save closed the modal, so the user had to reopen Settings to continue. Apply
runs the same save, keeps the modal open and refreshes the dependent groups
(MCP sync, custom model endpoints, CLI management) in place.
2026-10-09 18:41:59 +08:00
Devvyn 26487f0ec5 fix(cli-install): install npm CLIs to ~/.local when the npm global prefix is not writable
installEnv() only redirected NPM_CONFIG_PREFIX inside the Docker container
(CODEMAN_IN_CONTAINER=1). On a native install with a root-owned system node
(prefix /usr) `npm install -g @deepseek-ai/dsh` run as the server user died
with EACCES (exit 243), so DeepSeek, pi and other npm-based CLIs could not be
installed from Settings. Ask npm for its global prefix and, when the server
user cannot write it, redirect to $HOME/.local like the container path does.
An operator-set NPM_CONFIG_PREFIX and a writable prefix are left alone.
2026-10-09 18:41:58 +08:00
DevvynandClaude Sonnet 5.5 8da4a07a60 fix(viewer): make in-document links in rendered markdown scroll to their heading
marked emits no heading ids and, with <base href="/">, a bare #section href points at the dashboard root, so [Install](#installation) in the File Viewer did nothing. The shared click delegate now resolves fragment links against the rendered document: GitHub-style slugs in data-md-anchor (never ids, so a heading cannot capture an app element), case-insensitive and percent-decoded, repeats numbered -1/-2, # = top, explicit ids supported, an unmatched fragment ignored instead of navigating.

Tests: slug/assign/find unit tests (CI gate) and a real-browser test clicking links in a File Viewer document, which fails without the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-09 17:01:44 +08: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
325 changed files with 30261 additions and 2145 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
@@ -1,5 +0,0 @@
---
'aicodeman': minor
---
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.40.0",
"version": "1.41.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1 -1
View File
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
+20 -1
View File
@@ -84,6 +84,23 @@ tmp/
# added there). No trailing slash so it still matches the symlink, not just dirs.
/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)
# 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
@@ -107,7 +124,9 @@ todo.md
@fix_plan.md
readme-preview.mjs
# Uploaded images land here under each session working dir (runtime artifact)
# Prompt uploads land here under each session working dir (runtime artifact);
# .claude-images/ is where they landed before the move.
.codeman-uploads/
.claude-images/
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
+2
View File
@@ -28,3 +28,5 @@ scripts/remotion/
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
CLAUDE.md
src/web/CLAUDE.md
docs/releasing.md
+1 -1
View File
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
+97
View File
@@ -1,5 +1,102 @@
# aicodeman
## 1.41.1
### Patch Changes
- ### Thanks
- @timkjr for four PRs: shells that reload from tmux's rendered history instead of replaying old full-screen repaints (#601), the full scrollback of a remote SSH session read from the remote tmux (#602) with no rows lost at the join (#609), and a phone shell Paste that inserts a multi-line block instead of running it line by line (#600).
- @aakhter for three PRs: Codex panes that page their transcript with the wheel and touch (#527, fixes #508), new and spawned tabs that land in tab groups (#528), and a Focus section of shortcuts at the top of the vertical rail (#582).
- @JDProfresh for three PRs: Claude Code's prompt-cache state per session (#607, step 1 of #554), the phone Attach button with camera-roll video uploads (#606, part two of #553), and an upload that no longer stalls the server on a dead network mount (#605).
- @Randalix for clickable `~/`, relative and hard-wrapped file paths plus a folder grid for browsing a whole folder of renders (#604, closes #603).
- @opticon454 for three PRs: a Working folder row with a Copy button in Session Options (#593), tab colour, pin and image watcher surviving a restart (#591), and Link Existing saying why it refused a case (#590).
- @ideaCompany, a first-time contributor, for rendering local HTML files as real pages in the File Viewer (#547), and for the careful security write-up behind it.
- @zomby1, a first-time contributor, for OSC 52 clipboard support (#611).
- @Hugo-DDT, a first-time contributor, for two PRs: MCP server sync reading Gemini's bare `url`, OpenCode's V2 layout and `opencode.jsonc` (#599, fixes #586), and DeepSeek permission prompts that no longer offer Approve/Deny buttons that cannot work (#610, from #598).
**HTML files open as pages, and markdown opens rendered from the terminal (#547).** Click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer now shows it as a real page, with its own scripts, styles, images and data files from its folder, instead of raw source. A **Page** pill switches to the source and its Edit button. The page runs in a sandbox with an opaque origin: it cannot read Codeman's cookies, use Codeman's API or read files outside its own folder. Network access is open, so a dashboard that loads Chart.js or fonts from a CDN works as it would opened from disk. Broad folders (`/`, home, `/tmp`, a drive's mount point) are refused, so put a report in a folder of its own. And a `.md` or `.markdown` path clicked in the terminal now opens the rendered markdown viewer everywhere (main terminal, tiles, split pane, touch), instead of the live log viewer that showed "streaming"; the MD pill still gives the source. Logs, `.txt`, `.json` and code keep the live log viewer.
**`codeman mcp`: drive Codeman from any MCP client (#615).** A stdio Model Context Protocol server with seven tools (list, start, send, wait, read, interrupt, delete), so Claude Code, Codex, Gemini CLI, Cursor or Claude Desktop can start worker sessions in any installed agent CLI, prompt them, wait for them and read what they did. It is a client of the existing HTTP API with the same guards as `codeman agent`: it refuses control characters in input, never acts on the session it runs in, and by default only touches sessions it started. Setup lines for Claude Code, JSON clients and Codex are in the README and the wiki page "Driving Codeman From An Agent".
**The codeman skill for every agent CLI (#613).** With **Codeman Agent Skill** on (App Settings > Agents & CLIs > All agent CLIs, still off by default), Codex, Gemini CLI, OpenCode, Antigravity, Grok Build, DeepSeek Harness and OMP sessions now get the same `codeman` skill Claude Code had, in `.agents/skills/codeman`, so an agent in any mode knows how to start and manage worker sessions. `codeman skill install --mode <cli>` installs it by hand.
**Tiles: move them by hand, or tear one off into its own window (#584).** With the camera gesture overlay on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it. With the new **Detach Tiles** setting (App Settings > Appearance, off by default), drag a tile's header out of the window and it pops out into its own window; drop it on another Codeman window's tiles and it moves there. Known limits: all Codeman windows in one browser share one saved tile layout, a drag between two browser profiles is not seen by the source window, and a drag-out held for more than a few seconds can be blocked as a pop-up.
**Focus section on the vertical rail (#582).** "Add to Focus" in a row's menu (or right-click a web tab) pins a shortcut to that tab in a small collapsible section at the top of the rail. The tab itself stays where it is.
**Tabs land in groups (#528).** A tab group's menu has **New session**, which starts a session straight into that group. A session an agent spawns lands right after its parent; App Settings > Appearance > Tabs > **Spawned tabs follow their parent** (off by default) keeps it moving with the parent.
**Folder grid and more clickable paths (#604).** Paths written as `~/...`, relative to the session folder, or broken across terminal lines are now clickable in the terminal and the Response Viewer, and folders are too: a folder opens a grid of its files (renders, screenshots, reports) that you can page through with the arrow keys.
**Phone Attach button and videos (#606).** On a phone the toolbar has an Attach button that opens the system file sheet directly, without raising the keyboard, and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into `.codeman-uploads/` like images.
**OSC 52 clipboard (#611).** A program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) now puts the text on your browser's clipboard, in the main terminal and in every tile. Read requests are refused, and a replayed buffer never copies.
**Prompt-cache readout (#607).** Claude sessions show whether their prompt cache is warm (`cache:until HH:MM`) or cold in the footer and in Session Options > Respawn, restored after a restart, with a reason when a launch will never report it. Display only, nothing changes behaviour.
**Faster tab switches to fullscreen Claude (#588, #583).** Switching back to a fullscreen Claude tab now loads only its current screen (a few KB) instead of replaying a 1 MB tail of old redraws (about 1.3 to 1.7 s saved per switch). The "Showing the most recent 1.0 MB" bar now appears only when there really is older history and you scroll to the top looking for it, gives a line count, and stays closed once you close it.
**Fixes.**
- **Shells reload cleanly (#601).** A shell tab, tile or split pane reloads from tmux's rendered history, so a prompt no longer appears four or five times and lines no longer go missing after a resize or a full-screen program.
- **Remote SSH sessions keep their scrollback (#602, #609).** Loading a remote session's full history reads it from the remote tmux in one bounded ssh round trip, where the local pane kept only a fraction (48 of 279 lines in one measurement, none after a reattach).
- **Codex pages with the wheel and touch (#527).** A Codex pane on its alternate screen pages its transcript with PageUp/PageDown from the first wheel notch or swipe, in the main terminal and in tiles, and never pages into an open permission dialog.
- **openpyxl spreadsheets preview (#612).** An `.xlsx` written by openpyxl with a cell comment, a table or a chart now opens in the File Viewer instead of failing.
- **Phone shell Paste (#600).** A multi-line paste in a shell is inserted as one bracketed paste and waits for Enter, instead of running each line as it arrives.
- **Restart keeps tab colour, pin and image watcher (#591).**
- **Session Options shows the working folder (#593)**, with a Copy button and a label that says when the folder is on a remote host or mounted into a Docker container.
- **Link Existing says why (#590)**, for example "Invalid case name format", instead of "Invalid request body".
- **Uploads on a dead mount (#605).** A prompt upload into a workspace on a network mount that stopped answering is refused at once instead of tying up a server thread.
- **MCP server sync (#599).** Gemini servers with a bare `url` sync as HTTP, OpenCode's V2 `mcp.servers` layout and `opencode.jsonc` are read.
- **DeepSeek approvals (#610).** DeepSeek Harness permission prompts show "Answer in the terminal" in the Approvals Inbox, the phone NEEDS YOU strip and push notifications, instead of Approve/Deny buttons the server always refused.
**Fixes applied while landing.** Remote SSH and Docker tabs keep their scrollback on a tab switch (they were about to lose it with #588). No colour bleed where remote history meets the live screen (#602, #609). A bare `~` in a Docker case no longer lists the host's home folder, and shell prompts (`user@host:~/proj$`) no longer turn into broken folder links (#604). Spawned sibling tabs keep their creation order (#528). An OSC 52 copy over plain HTTP gives keyboard focus back to the terminal (#611). A refused video upload answers instead of hanging, and the zh-CN "Attach" label of the tile grid is back (#606). The Working folder row fits on phones, with zh-CN text (#593). OpenCode sync no longer stalls on a server named `servers` (#599). Moving a tile to another window no longer overwrites that window's saved layout (#584). HTML pages: a `~/` link to an `.html` file renders as a page instead of falling back to source, `/tmp` is refused even when `TMPDIR` points elsewhere, symlinks cannot get around the hidden-file and file-type rules, workspace confinement is re-checked on every request, media in a page can seek, and the service worker never caches a page (#547). Codex users of `codeman mcp`: the config now forwards the session id, so its self-protection works (#615).
## 1.41.0
### Minor Changes
- 87f1c9c: ### Thanks
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
![Codeman tile grid: six live agents powering on and off with the CRT tile animation](https://raw.githubusercontent.com/Ark0N/Codeman/08694b5862534b2b1224e9e272ad9858c787ebef/release-1.41/tiles-crt-stats-800.gif)
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
**Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
## 1.40.0
### Minor Changes
+25 -167
View File
File diff suppressed because one or more lines are too long
+106 -18
View File
@@ -24,7 +24,7 @@
</p>
<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>
**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
- **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
- **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
- **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)
- **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)
- **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
- **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.
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
### 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).
- **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).
- **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.
- **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
@@ -294,7 +297,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 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).
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
- **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">
</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
@@ -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
- **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
- **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
- **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
- **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
- **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
- **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
---
@@ -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.
- **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.
- **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).
@@ -669,7 +685,7 @@ These run for **every** request — before auth, even on the default no-password
### 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
- **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`
### 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 |
| ------------------------------- | ------------------------------------------------------------- |
| `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+Tab` | 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+Shift+R` | Restore terminal size |
| `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+?` | Keyboard help |
| `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)
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">
<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>
#### Step 1: install it
| 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`) |
| 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 |
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) |
| 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 → 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
@@ -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 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>`.
---
@@ -944,6 +964,74 @@ codeman tui --list # numbered session list (plain tex
codeman tui 3 # attach to session 3 of that list
```
### `codeman agent` — session-to-session verbs in every CLI mode
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
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
### MCP server (`codeman mcp`): drive Codeman from any MCP client
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged.
```bash
# Claude Code
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
```
```json
{
"mcpServers": {
"codeman": {
"command": "codeman",
"args": ["mcp"],
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
}
}
}
```
```toml
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
[mcp_servers.codeman]
command = "codeman"
args = ["mcp"]
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
tool_timeout_sec = 120
```
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `"CODEMAN_PASSWORD"` to `env` when the server has a password (or let it read the data dir's `.env`). Without the `codeman` command on PATH (a git checkout run without the installer), use `node /path/to/Codeman/dist/index.js mcp`.
| Tool | Does |
| ---- | ---- |
| `list_sessions` | Every session you can see, with its run mode, status, folder, and whether these tools may prompt it |
| `start_session` | A new worker in a case (a fresh scratch folder, or an existing case) in any installed CLI; waits until it can take a prompt |
| `send_prompt` | Types a one-line prompt and presses Enter; optionally waits for the end of the turn |
| `wait_for_session` | Waits for a signal (`stop`, `blocked`, `idle`, `working`, `exit`) or a literal marker in the output |
| `read_output` | The last answer, the whole conversation, or the last bytes of the terminal |
| `interrupt_session` | One Escape: stops the current turn, keeps the conversation |
| `delete_session` | Closes a worker (its case folder stays) |
Rules the server enforces rather than documents:
- **It never guesses a server.** The URL comes from `CODEMAN_API_URL` or `--url`; without one it still starts and lists its tools, and every call says how to configure it.
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions this server started (and, run inside a Codeman session, that session's own workers). Listing, reading and waiting work on everything. `codeman mcp --allow-any-session` lifts the limit, for a user who wants an assistant to drive their existing tabs. It never acts on the session it runs in.
- **Waits fit the client.** A wait defaults to 45 s (Codex cuts MCP tools off at 60 s by default) and is capped at 600 s; `timed_out` / `still_working` means call again.
- **Both protocol eras.** It answers the 2026-07-28 revision (per-request `_meta`, `server/discover`) and the `initialize` handshake of 2024-11-05 to 2025-11-25.
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
+125 -27
View File
@@ -24,7 +24,7 @@
</p>
<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>
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
@@ -48,11 +48,12 @@ codeman web
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
- **智能体也能驱动它**:[codeman 技能](#智能体技能从这里开始)(适用于所有智能体 CLI)、[`codeman agent`](#codeman-agent每种-cli-模式都能用的会话间命令) 和一个 [MCP 服务器](#mcp-服务器codeman-mcp从任意-mcp-客户端驱动-codeman),让一个智能体在任意 CLI 里拉起工作会话、给它们发提示、等它们完成并收回答案
- **什么都不会丢**:tmux 让会话挺过重启和断网,输入精确一次送达,完整的回滚缓冲区回放
- **自托管、私有**:默认仅环回、MIT 许可、无遥测,完全运行在你自己的机器上
<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>
---
@@ -63,12 +64,13 @@ codeman web
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,开机自启),或暂不启动。不选就不会有任何后台进程。
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。手动运行的 `codeman web` 仍默认仅环回。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
- **三个问题,全部在开头问完。** 仪表盘怎么访问、(可选)这台机器在你的 tailnet 里叫什么,以及是否把 Codeman 作为后台服务运行(systemd/launchd,开机自启;直接回车即为「是」)。所有需要你出面的事,包括对所有缺失软件包的一次性同意、一次 sudo 密码和 Tailscale 登录,都在构建之前完成,所以编译期间你可以走开。
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**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` 仍默认仅环回。
- **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.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),也可以选择跳过、稍后自行安装。安装完成后:
@@ -213,6 +215,8 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触;在 Codex 会话上还会显示 `⇧←` / `⇧→`(Shift+Left / Shift+Right:编辑上一条排队的消息 / 在提示栈里回退)
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
- **附件按钮**:麦克风旁边的图片图标会直接打开系统的照片与文件面板,不会弹出键盘。照片和相册里的视频(mp4、mov、webm,最大 1 GB)会上传到会话所在的文件夹,文件路径落在你的提示里,但不会自动发送
- **在 shell 里粘贴**:多行粘贴会作为一整块插入并等你按 Enter,而不是一边到达一边逐行执行
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动;折叠屏手机(iPhone Duo)上对话框会避开铰链,开合设备也绝不会被误判成键盘弹出
@@ -221,7 +225,7 @@ codeman web --https
# 在手机上打开: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. 读懂仪表盘
- **标签(顶部)** —— 每个会话一个。`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` 插入换行。
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
### 4. 与智能体对话
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
- **粘贴或拖放图片**,直接进入会话。
- **粘贴、拖放或附加图片和视频**,直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行。
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行,包括 `~/` 路径、相对路径和被折成多行的路径。`.html` 报告会以真实网页打开,Markdown 会渲染后打开,文件夹则以其文件的网格打开。
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。用 OSC 52 复制的程序(Claude Code 的 `/copy`、tmux 的 yank、vim)也会把文本放进你浏览器的剪贴板。
### 5. 让它自主运行
@@ -295,7 +299,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
### 7. 运维与维护
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场动画、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场与平铺动画(有自己的 **Animations** 分区)、通知(toast 和浏览器通知的显示时长可调)、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。**Apply** 按钮保存设置但不关闭设置窗口。
- **让它在后台运行** —— `codeman web -d` 脱离你的 shell(`--status`、`--stop`);`codeman service install` 把它装成 systemd 用户单元 / macOS LaunchAgent,重启后自动回来。两者都会先确认服务器真正应答再报告成功,也都拒绝在同一个数据目录上启动第二个服务器。见[让它在后台一直运行](#快速开始--安装)。
- **自更新** —— 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">
</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)
<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>
每个标签一眼就能看出状态。运行中的会话保持绿色状态点。会话停下来等待输入时,标签变**黄**:稳定的描边、着色的背景、黄色的点,上面叠一层缓慢的呼吸光晕。当权限提示或提问**挡住**了智能体,标签变**红**,脉动更快。底色永远不会闪灭,所以哪怕只瞥一眼(或截一张图)也能读到真实状态;标签被选中时描边依然可见,页面刷新后会从服务端重新装载待处理的提醒,因此一个被挡住的会话绝不可能藏在一个看起来正常的标签后面。
@@ -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` 才真的找得到。机密永远不会写进单元文件
- **设置里的诊断**:**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 安装会被报告为不可更新)
- **底栏里的 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 绝不收集或保存凭据
- **在自定义文件夹里创建 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)
- **自定义模型端点**(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)
@@ -462,10 +476,15 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Terminal & Input 启用
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
- **文件查看器按钮** _(可选)_ —— 页头新增一个按钮,一键切换内置文件浏览器面板;在 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
- **页头里的套餐用量** —— 页头实时显示 Claude 订阅用量(5 小时窗口与每周窗口),数据来自 Codeman 在拉起 `claude` 时临时交给它的 statusline 导出器,绝不会写进你的设置文件;Codex 的限额则来自它自己的 app-server。按设备生效:桌面默认开,手机默认关
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的入场动画,覆盖标签、智能体窗口、终端面板和连接线
- **提示缓存状态**:每个 Claude 会话都会显示它的提示缓存是热的(`cache:until HH:MM`)还是冷的,显示在页脚(前提是你没有自己的 statusline)和 Session Options → Respawn 中,并给出重新缓存的代价,压缩上下文后还会附一条说明。只做显示,重启后依然保留
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序。导轨上的 **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>` 为前缀,使多主机配置不再含糊
---
@@ -480,7 +499,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
- **挂到你已经在跑的容器上** —— 在 Docker 面板勾选 **Attach to an existing container**,就能把 case 链接到一个现成容器,而不是新建一个。Codeman 只 `exec` 进去,绝不启动、停止、重启或删除它;一个被接管的容器可以在不同目录下支撑多个 case,**复制一个已有 case** 会用同一容器上的兄弟 case 预填表单。多用户模式下仅管理员可用,因为容器的挂载属于启动它的人。
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上用 `docker load` 载入,再导入到一个新案例即可继续。
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
前置条件:只需 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」徽标。
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
- **完整的回滚历史**:加载远程会话的历史时,会通过一次有上限的 ssh 往返直接从远程 tmux 读取,所以重新附着或切换标签之后,会话依然保有完整的回滚缓冲区,历史与实时屏幕衔接处也不会丢行。
在 **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、自动延长、设备上下文审计日志)
- **按 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 会话与跳过权限需要按用户显式授权
@@ -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、配置目录)对非管理员用户会被钳制
- **路径限定** —— 文件路由在边界检查前先 `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
### 供应链与隔离
@@ -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+Tab` | 下一个会话 |
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
@@ -714,6 +734,10 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
| `Ctrl/Cmd+L` | 清屏 |
| `Ctrl+Shift+R` | 恢复终端尺寸 |
| `Ctrl+Shift+V` | 切换语音输入 |
| `Ctrl+Shift+G` | 打开或关闭平铺网格 |
| `Alt+Shift+方向键` | 聚焦左、右、上、下方的窗格 |
| `Ctrl+Shift+方向键` | 把聚焦的窗格移动一格(移进空格子,或与邻居交换) |
| `Alt+Shift+Enter` | 放大聚焦的窗格,或恢复网格 |
| `Ctrl/Cmd +` / `-` | 字体大小 |
| `Ctrl/Cmd+?` | 键盘帮助 |
| `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 步:安装
| 方式 | 命令 | 范围 |
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 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`) |
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
| 内置 CLI | `codeman skill install --case <name>` | 仅一个 case |
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | 每次在某个 case 创建 Claude 会话时自动注入(`agentSkillEnabled`,跨设备同步,默认关闭) |
| 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
| 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 步:开口要
@@ -796,7 +824,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 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 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)
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"
}
]
}
]
}
+5
View File
@@ -22,6 +22,8 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/tab-rail-search.browser.test.ts',
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -34,6 +36,7 @@ export const BROWSER_TEST_GLOBS = [
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/terminal-tile-scroll.browser.test.ts',
'test/terminal-touch-paging.browser.test.ts',
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
@@ -45,6 +48,8 @@ export const BROWSER_TEST_GLOBS = [
'test/spreadsheet-preview.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
'test/markdown-anchor-links.browser.test.ts',
'test/fullscreen-tab-switch-capture.browser.test.ts',
];
/**
+232 -13
View File
@@ -43,8 +43,9 @@ payload return `{ "success": true, "data": {} }`.
> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`,
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect),
> `GET /html-view/:cap/*` (a [rendered HTML file](#rendered-html-files) and its
> assets), and the `GET /ws/sessions/:id/terminal` WebSocket upgrade.
>
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
@@ -87,6 +88,93 @@ the HTTP status.
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)
Three calls block until something happens instead of answering immediately. They
@@ -456,20 +544,105 @@ client that opens many concurrent waits against one session will still hit the c
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
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`.
`truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older
lines in tmux, so the remainder is more than `fullSize - retainedBytes`), and
`captureCols`/`captureRows` when the pane's geometry was read. `paneHistoryLines`
(present when the body is a pane capture whose geometry was read, like
`captureCols`/`captureRows`, and absent for a remote SSH session, whose local pane
cannot see the remote history) is the number of scrollback rows tmux holds above the
visible frame, which is the most a `full=1` request can add. `truncated` describes
the byte stream instead: for a pane with `paneHistoryLines: 0` (a fullscreen CLI in
the alternate screen) the bytes a `tail` cut dropped are earlier repaints that no
request returns. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning |
|---|---|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
## The `codeman agent` CLI (client over these endpoints)
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
A `multipart/form-data` body with one `image` part. The file is written into the
session's workspace as `<workingDir>/.codeman-uploads/paste-<ms>-<hex>.<ext>`, and
`data` carries `path` and `filename` for the client to type the path into the
prompt. The folder is Codeman's own: hidden, created on first use with a
`.gitignore` containing `*` (written once, never over a file already there), and
cleaned up the way pasted images always were: `paste-*` files older than 7 days
go in an hourly sweep, and the folder goes when the last session of that
workspace is killed. Uploads made before this release sit in `.claude-images/`;
that folder receives nothing new, and is swept and removed the same way for one
release. A remote (SSH) session answers 400, since the file would land on the
Codeman host under a path the remote agent cannot read. A Docker session of an
owned case is fine, its workspace is bind-mounted at the same absolute path; an
adopted container (`owned: false`) mounts nothing, so its agent can open the file
only if the container itself exposes that host path.
The optional query flag `?kind=video` selects a second path for a camera-roll
video: the file streams to disk instead of being buffered, under a far larger
per-file cap (`MAX_PASTE_VIDEO_BYTES`, 1 GB by default, env
`CODEMAN_MAX_PASTE_VIDEO_BYTES`), and only video container types are accepted
(mp4, webm, mov, m4v, ogv, single-sourced from the file viewer's playable set).
The container is checked from the first bytes as
the body arrives; a file whose bytes do not match its declared type is unlinked
and answered 415, one over the cap is unlinked and answered 413, and a transfer
that dies midway leaves nothing behind. Because one video can be 1 GB,
`?kind=video` draws on its own rate limit, 3 uploads per minute per (IP, session),
beside the 30 per minute the image path keeps; without the flag the route is
byte-identical to the image-only version, same 50 MB image cap and same 30/min
bucket. The response shape is unchanged (`path`, `filename`).
## Rendered HTML files
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
(`src/web/routes/html-view-routes.ts`).
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute,
relative to the session's workspace, or `~/` for this host's home, which a Docker
case refuses with `400` since its `~` is the container's; a non-empty string of at most 4096 characters
without NUL or line breaks, and no other keys) answers `data: { url }`, a
`/html-view/<cap>/<file>` URL. The capability grants GET access to the files under
that file's own folder, so a page's relative CSS, scripts, images and data files
resolve. It is held in memory only (a restart invalidates it), has a rolling TTL
(`CODEMAN_WEBVIEW_CAPABILITY_TTL_MS`, default 12 h), is reused for the same folder,
and is revoked on logout, admin logout and user deletion. Refusals:
- `400`: an invalid body, a remote (SSH) session, or a file that is not HTML (the
symlink-resolved name counts too);
- `404`: no such file;
- `403`: a file outside the workspace that the attachment guard blocks (a blocked
tree, or `attachmentConfineToWorkspace` on), a file in a hidden directory (inside
the workspace, one below the workspace root), or a file directly in a folder too
broad to serve recursively: `/`, the home folder, `/tmp`, `/var/tmp`, `$TMPDIR`,
the cases and user-space roots, a drive's mount point (`/mnt/d`), or any folder
above one of those (`/home`, `/Users`).
**`GET /html-view/:cap/*`** serves the page and its assets. It needs no cookie: the
capability in the path is the credential, because the page runs in an opaque origin
that never sends one. It is not under `/api`, so it has no `/api/v1` alias and no
envelope. `GET` and `HEAD` only; a CORS preflight on a live capability is answered
with `Access-Control-Allow-Methods: GET, HEAD`. What it serves: files under the
capability's folder whose path (requested and symlink-resolved) has no dot segment
and whose extension is on an asset allowlist (html, css, js/mjs, json, map, txt,
csv, md, svg and common image, font, wasm, audio, video and PDF types), never
outside the folder, never a blocked tree, and, for a folder outside the workspace,
not while `attachmentConfineToWorkspace` is on. Every response carries
`Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads`
as its whole policy (never `allow-same-origin`, so the page cannot read Codeman's
document, cookies or API, even opened as a top-level tab; and no fetch directive, so
by the owner's decision the page may load from and send requests to any host), `Cache-Control: no-store`,
`Referrer-Policy: no-referrer` and `Access-Control-Allow-Origin: *`. A `Range`
request is answered with `206` + `Content-Range` (`416` past the end), like
`file-raw`. Every refusal is a plain-text `404 Not found`; with a password set, an
unknown or revoked capability is the auth hook's `401` instead.
## Session lineage (`parentSessionId`)
@@ -522,6 +695,52 @@ most 64 characters, but treat it as untrusted text. A `statusline` or `screen` v
persisted and restored after a server restart until the next report replaces it; a
`config` value is read again at every pane start, attach and relaunch instead.
## Prompt cache (`promptCache`)
Claude-mode session state (`GET /api/v1/sessions`) carries the main conversation's
prompt-cache state as Claude Code last reported it on the statusline (v2.1.251+), with a
change-only `session:promptCache` event (`{ sessionId, ...promptCache }`):
```json
"promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 }
```
| Field | Meaning |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `warm` | Whether the cached prefix is still within its TTL. The only field always present. |
| `ttl` | `5m` or `1h`; absent when Claude reported neither. |
| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. |
| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. |
| `misses` | Requests that re-processed content the cache already held. |
| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. |
| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. |
| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. |
| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. |
The whole object is absent until the first report, and restored after a server restart
(so the readout is not blank until the pane's next render) rather than recomputed; a value
whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm cache hits
`expiresAt`, so the cold flip arrives on its own. A post-`/clear` conversation switch drops
it. Shown in Session Options → Respawn and, for sessions without a statusline of their own,
as a `cache:until HH:MM` / `cache:cold` group in the terminal footer.
Beside it, `statusLineExporter` says whether this launch carries the statusline exporter
that sends the report, decided when the pane starts: `injected` (it does), `collection-off`
(Plan Usage was off at launch), `workspace-statusline` (the workspace's
`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or
`remote` (remote and Docker sessions). Absent when unknown.
## Placing a new session in a tab group (`tabGroupId`)
`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional
`tabGroupId` (1 to 100 characters): the id of a group in the caller's tab layout
(`GET /api/v1/tab-layout`). The session is created as usual and placed at the end
of that group in the same layout write; the response then also carries the
committed layout as `tabLayout`. An id the caller's layout does not contain (a
deleted group, another user's group) is ignored and the session lands where it
otherwise would, never a `400`. A malformed value (`null`, an empty string, a
non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
@@ -817,10 +1036,10 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
File diff suppressed because one or more lines are too long
+7 -5
View File
@@ -77,11 +77,13 @@ A test that starts a server binds an ephemeral port, never a fixed one:
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
excepted).
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
convention in `test/mobile/README.md` for now.
any `WebServer` built under `test/` on a non-zero port.
- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
`address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
binds an ephemeral port too; read it from `server.boundPort`.
- The one exception is `test/codex-predictive-echo.test.ts`, which starts a separate lab
server process on port 3222 that the guard cannot see.
- Never port 3000: that is the live instance.
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
+18 -4
View File
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
interface CliEntry {
id: CliId; // 'codex'
label: string; // 'Codex' — shown in menus
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
accent: string; // single hex colour
enabled: boolean;
stock: boolean; // set by the loader; a custom entry can never claim it
@@ -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.
### 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
`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.
@@ -265,9 +277,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
+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
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`.
- **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`,
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
### In the browser
1. Click **⏰ Cron** in the header.
1. Click **⏰ Cron** in the bottom toolbar.
2. Click **+ New Job**.
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
+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
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
[--case <name>]`, or the synced `agentSkillEnabled` App Setting for automatic
per-case injection on Claude session create). The skill carries the guard, the
safety rules, and verified wait/orchestration recipes.
[--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
automatic per-case injection on session create, into the directory each CLI scans for
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:
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
stale `true` from one transport drop can never revive the NEXT clean exit.
## Terminal history from the remote tmux
The local pane runs the ssh attach, so it only sees the remote tmux client's
full-screen repaints and keeps a fraction of the scrollback (none after a
reattach). A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a
remote session therefore reads the scrollback rows from the REMOTE pane in one
ssh round trip (`src/remote-pane-history.ts`) and splices them above the local
visible frame and caret. Bounded (5 s for the round trip, at most 1.5 s queued
for a slot in the shared ssh limiter) and fail-soft: any failure keeps the
local capture, and a transport failure (ssh exit 255, a timeout, output that is
not the script's) also skips that host for 30 s; a failure of the remote
command itself (a remote tmux session that is gone) affects that session only.
How much history exists is the remote tmux's own `history-limit`, which the
launch command does not set (tmux's default is 2000 lines).
The same round trip also reads the remote SCREEN, in the same tmux command list
as the history, because two seams would otherwise drop rows at the join:
- **Rows that scroll during the round trip.** The local frame is captured only
once the round trip is back, so rows that scrolled off the screen in between
are in neither the remote history nor the local frame (measured before this
read existed: 13 lines lost with a 150 ms round trip and fast output).
- **A shared remote session with a taller client.** Under `window-size latest`
a remote session attached elsewhere is sized by its latest client; when that
client is taller than this pane, the rows above this pane's frame are only
on the remote screen (measured before: 20 rows).
Both are rows of the remote screen above where the local frame begins, so the
load keeps them between the history and the frame (`joinScrollbackAndFrame`,
`findFrameInScreen` in `src/tmux-manager.ts`). The frame is placed by matching
its top rows against the screen as text (escape sequences and trailing blanks
ignored, since the two tmuxes need not encode attributes alike); the screen's
trailing blank rows and its last content row, which output in flight can still
change, are left out of the match, the overlap must be at least three rows
with two of them non-blank, and the highest match wins. A placement too low
would REPEAT the rows between it and the true one, so a shorter overlap (a lone
`$` row that happens to match after a spinner row changed) is not trusted.
When the frame cannot be placed (more output scrolled than the screen held, or
nothing matches), the join is history over frame, as without the screen read.
That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a
live region (spinner, composer, footer) pinned to the bottom and inserts new
rows above it, so the frame is not a shifted copy of the screen and is not
placed. A plain shell places, unless the frame's top row is the second half
of a wrapped line (the screen holds that line as one joined row, so nothing
matches and the join is history over frame). What is left: when the screen
repeats a whole block of rows, the frame can be placed at the higher copy,
which leaves out the rows between the two copies.
The history, the history size and the screen are ONE tmux command list
(`capture-pane … \; display-message … \; capture-pane …`), not separate `tmux`
runs. tmux runs a client's command list back to back without reading pane
output in between, so the screen begins exactly where the history ends. As
separate runs the shell kept writing between them and the rows that scrolled in
those milliseconds were in neither capture (measured with output streaming: 1-2
rows per load on a LAN host, every read on a local busy pane). This rests on how
tmux's command queue behaves, not on a documented guarantee. A random marker row
(`display-message`) separates the history from the size and the screen, since
tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no
newline after `capture-pane -p` output, so when the history ends inside a
wrapped line the marker is glued onto that row; the parser accepts the marker
at the end of a row and re-terminates the history. The remote `tail -c` bound
applies to that whole output, history first, and is the configured byte cap
plus 1 MiB for the marker, size and screen, so a cut lands in the history; the
route trims what it returns to the cap itself.
## File access over SSH
A remote case's `workingDir` is an absolute path on the **remote** host
@@ -270,6 +335,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
+18
View File
@@ -374,6 +374,23 @@ Two registration paths, with **different trust**:
file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above.
**Folder grid grants** (`POST /api/sessions/:id/folders`, `src/folder-listing.ts`)
follow the explicit-POST trust level: a deliberate, Origin‑guarded request, allowed
cross‑workspace subject to the same guard (blocklist on the folder AND on every
listed file, `confineToWorkspace` when set, hidden entries and unsupported types
left out). The grant is an id for a resolved folder; files are served by
`folderId` + a bare file name only (no `/`, `..` or NUL, supported type), and each
request re‑runs `resolveServableAttachmentPath` on the joined path, so a file that
was swapped for a symlink to a blocked target after listing is still refused.
The file a symlink resolves to is held to the attachment gate as well (supported
extension, regular file), so a supported-looking name cannot serve an arbitrary
file, and a FIFO or device node is never opened.
In multi‑user mode a regular user is held to the filesystem path picker's rule
(`folderAllowedForUser`): the folder, and again each served file after symlink
resolution, must sit inside their own `userSpacePath` or the session's
`workingDir`, else 403. User spaces live inside `homedir()`, so without it one user
could list and read another's space.
### SSE log‑tail route — intentional extra read roots
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
@@ -529,6 +546,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
- **Rendered HTML files (`/html-view/<capability>/`) are the other capability-gated route that skips cookie auth.** A clicked `.html` file renders through it so an agent's report can be viewed from any device. The exemption is narrower than the proxy's: `GET`/`HEAD` only (plus the page's CORS preflight, which `registerSecurityHeaders` answers itself with `Access-Control-Allow-Methods: GET, HEAD` and never hands to a route), the capability in the path only (no `Referer` form), and it is a separate store, so a web-tab capability never opens a page and vice versa. The capability is minted by an authenticated `POST /api/sessions/:id/html-view` (session ownership via `findSessionOrFail`, the attachment guard for out-of-workspace files), held in memory with a rolling TTL and revoked with the web-tab ones on logout, admin logout and user deletion. Every response carries `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page is opaque-origin even when opened as a top-level tab and cannot read Codeman's document, cookies or API. **Network access is open, by the owner's decision (2026-10-13):** that `sandbox` directive is the whole policy (it replaces Codeman's own `default-src 'self'` CSP and carries no fetch directive), so a rendered page behaves like a file opened from disk in a browser. What a page CAN do: load scripts, styles, fonts, images and media from any host (an agent's dashboard pulling Chart.js from a CDN), make fetch, XHR, WebSocket, beacon and form requests to any host, and read the files of its own folder through its capability. What it CANNOT do: act as Codeman's origin, read Codeman's cookies or document, use Codeman's API (a write arrives with `Origin: null` and the Origin guard refuses it; a read's response stays unreadable, because Codeman answers CORS only for its own localhost origins, and with a password set the request carries no session cookie either), or read any folder other than its own. The accepted consequence is that a page can send what it can read, its own folder, to any host, which is why the folder rules below are the boundary that matters. `Referrer-Policy: no-referrer` keeps the capability URL out of the `Referer` the page's third-party requests carry. Pinned by the exact-header test in `test/routes/html-view-routes.test.ts`. It may read its own directory tree (non-dot, asset-allowlisted, realpath-confined, blocklist applied; the dot and extension rules hold for the symlink-resolved target as well as the requested name), so minting is refused for an HTML file in a hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a directory above one (`/home`, `/Users`). Pinned by `test/routes/html-view-routes.test.ts` and the html-view block in `test/webview-auth-exemption.test.ts`.
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
---
+101 -34
View File
@@ -45,20 +45,28 @@ or settled a question the spec left open. The invariants as built are in
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
compare with the last English value, not the translated DOM.
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
(constants.js) picks the grid this tab last had, else an open split's two sessions, else
the open sessions in tab order, the active one always included and focused, and
`tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
(from tab order) to the count. A remembered grid comes back with its tiles first, in
their cells, then sessions in tab order, to the count in total: the count is a shape
change under the cell model's rule (`reformTileCells`: the tiles keep their row and
column when all fit, else they pack in reading order) and the added tiles fill the empty
cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
remembered grid still wins over an open split: the split closes and its sessions are not
seeded first. A page-load restore brings back exactly the stored grid, whatever the
count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
session among them and focused (owner answer: N, not N+1).
decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same
`toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left
it: which session sits in which cell, holes included, its tile count, divider sizes,
focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never
filled to the remembered count and never trimmed to the window (a window too small
for it shows the focused tile alone until it fits, the arrangement kept). A session
that no longer exists frees its cell, which the ranking fills. Only with nothing
stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an
open split's two sessions, else the open sessions as `rankTileSessions` orders them
(owner request: "prefer to load in tiles that are working and then the most recent,
so the oldest don't get opened"): WORKING first (the most recently started turn
first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow
tab alerts), then the rest by most recent activity, tab order breaking ties; the
active one always included and focused, and `tileGridSetForCount` trims it (from the
end, the session to focus kept) or fills it (from the ranking) to the remembered count
(default 6, at most what the window fits). The states and stamps are the home
screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still
wins over an open split: the split closes and its sessions are not seeded first. A
page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab
with the grid closed opens what the toggle would with that session among the tiles and
focused, never past the count (owner answer: N, not N+1): it joins the first empty cell
while the grid holds fewer than the count, else it takes the last tile's place.
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
to right click over the tile button to adjust it"). It replaces the button's native title:
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
@@ -80,9 +88,10 @@ or settled a question the spec left open. The invariants as built are in
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
element close it (the single view a close starts focuses its terminal when its replay
lands; a menu left open behind that would send its keys there). A pick is remembered per
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
stays, the others leave from the end or join from tab order, filling empty cells first,
device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to
it, its tiles first in their cells); with the grid open it re-forms it
(`_reformTileGrid`): the focused tile always stays, the others leave from the end or
join from the ranking, filling empty cells first,
every joining tile mounted and laid out before any connects (one fit, one PTY resize
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
"Open group as tiles", Run) still add up to the cap of 6.
@@ -97,7 +106,10 @@ or settled a question the spec left open. The invariants as built are in
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
own `settle` style, the default of App Settings → Animations → Tile Animations, which
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
@@ -389,16 +401,34 @@ the harness/model request; see "As built")
### Persistence
Decided: per device, restored on reload. Stored in localStorage key
`codeman:tile-grid`:
Decided: per device (per browser), restored on reload when it was open, and by
the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
server:
```json
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
"colFr": [1, 1, 1], "rowFr": [1, 1] }
{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
"zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
```
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
duplicate ids on load. Never restored in a solo window.
Session ids and the layout, never content. `ids` are the CELLS in reading order,
`null` for an empty one. `count` is how many tiles the user's own last change
left (open, add, remove by hand, a count picked): a session that goes away by
itself (deleted, popped out, its socket refused) does not lower it, so the next
time the grid opens the ranking fills that place, while a hole the user made
stays. It is written on every change (a move, a divider drag at pointer-up, a
tile added or removed, a count picked, a focus, a zoom) and kept, as
`open: false`, however the grid closes: the toggle, a non-tiled tab,
`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still
frees its cell), Home, the width gate, the last tile, "Open group as tiles",
closing or killing sessions. Nothing is written while a stored grid is being
put back, so a half-built grid never overwrites it.
A pure sanitizer drops unknown, deleted, detached and duplicate ids on load,
reports the cells their sessions freed (`freed`), and derives `count` for a
value written before it existed (the number of sessions the cells name); the
old packed `ids` (no nulls) read as cells with no hole, and anything that is not
a v1 object is ignored. The format stays `v: 1`, so an older build still reads
a newer value (it ignores `count`). Never read or written in a solo window.
The restore runs INSIDE `handleInit`, in place of its initial
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
@@ -409,6 +439,17 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
against the live list without rebuilding tiles that are still alive.
A cell freed since the grid was stored is filled during that restore, from a
ranking that knows each session's status and stamps (the init payload) but not
yet its pending approvals: `seedApprovals` asks the server for them
asynchronously, and the restore has run by the time they land. So on a reload a
session waiting on a permission dialog or an unseen finished turn ranks with the
quiet ones for that one fill (working sessions still rank first). Accepted: a
fill held back for the approvals would open fewer tiles, which can be another
shape, and then reshape the grid and move the user's tiles a second after the
reload; so approvals that land later never re-form a restored grid. The Tiles
toggle, run once the page has loaded, ranks with them.
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
@@ -815,7 +856,8 @@ Separate follow-up PRs worth doing (see "Follow-ups").
open, and an open menu owns the Escape (it closes alone and the keyboard goes
back to the Tiles button, like the tab-group menu).
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
- **No secrets in localStorage**: the stored grid holds ids only.
- **No secrets in localStorage**: the stored grid holds session ids and its
layout only, never content.
- **Memory**: everything a tile creates is released in `destroy()`.
## Delivery: two PRs
@@ -1008,13 +1050,17 @@ exits green. Use the browser runner for those files and read the file count.
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
a "Load full history" action inside a tile. (Done since: a tile pages a
hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
#555 route, and hand-reports a plain click while its session has
`cliMouseTracking` on, both through the primary pane's gates aimed at the
tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
tile leaves the wheel to xterm.)
4. Tile parity extras: a "Load full history" action inside a tile. (Done
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
the primary pane's #555 route, hand-reports a plain click while its session
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
renderer as SGR wheel reports from its own cells
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
since xterm turns it into a horizontal no-op off macOS.)
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
@@ -1053,7 +1099,8 @@ exits green. Use the browser runner for those files and read the file count.
is on right-click of the button (its title says so, as do the wiki and the
Help modal). Superseded in part by decision 10: right-click is now the count
menu, and a remembered grid is filled to the count instead of opening
exactly as stored.
exactly as stored; decision 11 then restored "exactly as stored" and put a
ranking in place of the tab order.
9. **No + in the tile header.** Decided by the owner ("remove the + button from
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
session in this case" went with it. Tiles are added from the Tiles button
@@ -1066,12 +1113,32 @@ exits green. Use the browser runner for those files and read the file count.
right-click menu offers 2, 4 and 6, remembered per device; the session
picker is gone, and decision 8's "picker on right-click" is superseded. The
owner's answers on the details: the count wins over a remembered grid's
size (its tiles first, in their cells, holes filled first, then tab order);
size (its tiles first, in their cells, holes filled first, then tab order;
superseded by decision 11: a click brings the remembered grid back as it
was, and only a count picked in the menu re-forms it);
Ctrl/Cmd+click with the grid closed opens the count in total, that session
focused; shrinking keeps the focused tile; only the toggle animates the
close; a remembered count larger than the window stays checked but greyed
and a click opens what fits; the close keeps its dimmed still until the
single view has painted (at most 700 ms); paced connect is in.
11. **The grid keeps the layout the user arranged, and a fresh one ranks by
work.** Decided by the owner ("when I hit the tiles button, it should prefer
to load in tiles that are working and then the most recent working, so the
oldest dont get opened ... when I moved around and modified it, save it per
browser the layout, so when I turn tiles off and on, always keep what the
last setting was, if there was no setting before take the working ones, that
ones needs input and then the most recent ones in order"). The layout
(cells and holes, tile count, divider sizes, focus, a zoom the user chose)
is saved per browser on every change and comes back exactly from the toggle,
however the grid closed, and from a reload when the grid was open (a grid
closed before the reload stays remembered for the toggle; the page shows the
single view); it is never filled to the remembered count nor trimmed to the
window. A session gone since frees its cell for the
ranking; with none left, the grid opens from the ranking (`rankTileSessions`:
working, then needing input, then most recent), which also fills every place
the grid fills on its own (a count picked in the menu, a freed cell, an open
split's fill). Supersedes decision 10's "the count wins over a remembered
grid's size"; the count menu itself, its counts and its other answers stay.
## Code anchors
+2
View File
@@ -46,6 +46,8 @@ Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a con
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (the first warm report whose `recache_tokens_if_cold` is null after one that carried it; `expected_rebuilds` rises only at the next request, so it is not read; never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
### Alternatives considered & rejected
| Source | Why not |
+2 -3
View File
@@ -63,9 +63,8 @@ provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
because that helper caches servers by port.
or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
read `server.boundPort`. Never 3000.
## Finding your way around
+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
dependencies and open a PR.*
The ⏰ **Cron** header button is opt-in. Turn it on in
**App Settings → Header & Panels**.
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
**App Settings → Header & Panels → Scheduling**.
## 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:
| Status | Meaning |
| --------- | -------------------------------------------------------------------- |
| `created` | The run started and a session was created. |
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
| `failed` | The prompt could not be resolved, or the working directory was gone. |
| Status | Meaning |
| ----------------- | ----------------------------------------------------------------- |
| `created` | The run record was created; the session has not started yet. |
| `session_started` | The session started; prompt delivery is still pending. |
| `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
same job to re-trigger.
+93 -8
View File
@@ -4,25 +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
supervising other sessions.**
Two routes. Start with the skill.
Three routes. Start with the skill, whichever CLI the agent is. Where the skill does not
reach (a pi or shell session), use the `codeman agent` commands. Raw HTTP is there for
everything else.
## The agent skill
A Claude Code skill that teaches the agent the whole API, so you ask in plain English
instead of pasting endpoint documentation into prompts.
A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP
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
| 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`). |
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
| Bundled CLI | `codeman skill install --case <name>` | One case. |
| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. |
| 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 → 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
`skills/codeman` you wrote yourself.
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
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
@@ -59,6 +68,82 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals.
## The `codeman agent` commands
The skill drives the API with `curl` and `jq`. Where it does not reach (a pi or shell
session, a machine without `jq`), the agent runs in the same environment, so `codeman agent`
packages the same verbs as shell commands. It is a thin client over the endpoints in
[the manual path](#the-manual-path), so auth and ownership apply unchanged, and it refuses to
act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"other sessions:
`codeman agent --help`"*.
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
ambiguous prefix.
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
prompt is output too, so ask for the marker in halves and wait on the joined form.
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
worker exited, `4` refused. `--json` prints the response's `data`.
## The MCP server
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP
client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor) can drive Codeman, from
inside a Codeman session or from an assistant on your desktop. It is a client of the same
HTTP API, with seven tools: `list_sessions`, `start_session`, `send_prompt`,
`wait_for_session`, `read_output`, `interrupt_session` and `delete_session`.
```bash
# Claude Code
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
```
```json
{
"mcpServers": {
"codeman": {
"command": "codeman",
"args": ["mcp"],
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
}
}
}
```
```toml
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
[mcp_servers.codeman]
command = "codeman"
args = ["mcp"]
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
tool_timeout_sec = 120
```
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `CODEMAN_PASSWORD` to
`env` when the server has a password.
- **It never guesses a server.** Without `CODEMAN_API_URL` (or `--url`) every call says how
to configure it.
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions it
started; listing, reading and waiting work on everything. `codeman mcp
--allow-any-session` lifts that, for an assistant that should drive your existing tabs.
- **Waits are short by default** (45 s, at most 600 s), so they fit a client's tool
timeout. A `timed_out` answer means call again.
## The manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
+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.
## 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
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
subscription plan.
## The prompt cache readout
**Claude only.** On the Respawn tab, above the loop controls.
Claude Code bills a turn against the cached conversation while that cache is warm, and lets the cache expire after a fixed idle time: one hour on a subscription within plan usage, five minutes on an API key, a cloud provider or extra-usage credits. The first turn after a longer break re-processes the whole context at the cache-write rate, which is why coming back to a big session after lunch costs more than the turns around it.
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
## The plan usage chip
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
+27 -3
View File
@@ -66,7 +66,7 @@ autocorrect: Enter adds a new line, and only Send delivers the text, as one past
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Image button attaches photos and puts their paths into the draft.
is parked. The editor's Image button attaches photos or videos and puts their paths into the draft.
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
editing the last queued message and walking the prompt stack.
@@ -100,13 +100,37 @@ looks exactly like a dead button.
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
into the Run dropdown.
## The Attach button
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
hand the agent a photo or a video. One tap opens the camera and photo library sheet, nothing
else: no editor, no keyboard. The chosen files upload and their paths land on the prompt, so
you then tap the terminal, write the rest of your message and press Enter. Videos (mp4, webm,
mov, m4v, ogv) upload as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the server changes it), so a long 4K clip takes a moment
on the way up.
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
not bring it back, so the tap on the terminal is how you get it again. That is the system's
behaviour around its own picker rather than a Codeman setting.
The paths go onto the prompt as unsent text, exactly like something you typed, which means
they travel with it. Tap **Compose** afterwards and the editor opens holding the paths *and*
anything you had already typed, so you can write in front of a path, fix a word, and Send.
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
so neither surface holds a half of your message. A multi-line message cannot make that trip
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
shows it is waiting. The Compose editor's own Image button remains for the times you are
already writing in the editor.
## Tapping, links and copying
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
which on a phone would unload the whole session view.
- **Tap a file path** an agent printed and the file-preview overlay opens; a log path opens the
log viewer. Works in scrolled-up transcript too.
- **Tap a file path** an agent printed and the file-preview overlay opens (Markdown rendered,
HTML as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up
transcript too.
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
because on a phone it is the only interaction that matters.
+16 -3
View File
@@ -90,7 +90,6 @@ every session or only the active tab.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
@@ -99,11 +98,23 @@ every session or only the active tab.
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. |
| Overview Home Screen | The phone home screen. On by default. |
### Animations
All per device, all off by default, applied as you pick them.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the
@@ -135,14 +146,14 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Allowed Tools | The list used by the explicit mode. |
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
| 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. |
| 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 reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
### Notifications
@@ -206,6 +217,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image. 50 MB by default. |
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
+14 -4
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. **Focus:** *Add to Focus* in a row's ⋯ menu (right-click on a web tab) pins a shortcut to that tab in a **Focus** section at the top of the rail, with or without groups; the tab itself stays where it is. Click a shortcut to switch to its tab, right-click it (or press the menu key) for *Remove from Focus*. Focus follows you across devices; whether it is collapsed is kept per device. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -73,6 +73,10 @@ precedence there.
One tab per session, in your order, and that order syncs across your devices.
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
headers and the Run menus keep theirs.
**Status is carried by the dot and the tab's own styling:**
| Look | Meaning |
@@ -210,6 +214,8 @@ TUIs render correctly.
Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
A fullscreen Claude tab keeps its history inside Claude, so switching back to it
reloads only the current screen, which keeps tab switches fast.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. That notice only
@@ -222,8 +228,12 @@ Worth knowing:
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. OpenCode's wheel and swipes page its own conversation
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
too. Other CLIs scroll locally.
(PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
and leave the terminal no history of its own (an older Codex that draws inline keeps
its history in the terminal and scrolls locally). The first small movement of a
gesture pages at once, then one page per half screen of travel; one finger on the
screen is one gesture however slowly it moves. In a grid tile or the split view's
second pane the wheel pages too. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
@@ -257,7 +267,7 @@ Two extras depending on the device:
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
| Ralph | Session Options | [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) |
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
| File Viewer | Header | [Working With Files](Working-With-Files) |
+36 -16
View File
@@ -16,23 +16,32 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift
## Opening a grid
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
(six until you choose; fewer if the window is too small or you have fewer sessions open).
You get the grid you last had, its tiles where they were, topped up with your open
sessions in tab order; if there is none, an open split's two first; otherwise your open
sessions in tab order, with the session you are on focused. With the grid open, the same
button closes it.
- **Tiles button**: one click shows the tiles straight away. If you have used the grid in
this browser before, you get it back exactly as you left it: the same sessions in the
same places, an empty place where you left one, the same number of tiles, your column
widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session
closed since frees its place, which is filled the way a new grid is filled (below).
Otherwise, or when none of those sessions is left, you get as many tiles as you last
chose (six until you choose; fewer if the window is too small or you have fewer sessions
open): an open split's two first; otherwise the sessions that are working (the most
recently started first), then the ones waiting on you (red and yellow tabs), then the
rest, the most recently used first, so the oldest are the ones left out. The session you
are on always comes along and is focused. With the grid open, the same button closes it.
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
count it opens and what a click and a right-click do.
count you chose and what a click and a right-click do.
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
and is what the next click opens. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
order. A count the window is too small for is greyed out, with the reason.
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this
browser. Picking a count opens the grid with that many tiles (the grid you left, its
tiles in their places, new ones in the empty places first), and it is what a click opens
when there is no grid to bring back. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join working ones
first, then the ones waiting on you, then the most recent. A count the window is too
small for is greyed out, with the reason.
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
the grid closed it opens what the Tiles button would show, with that session among them
(still the count you chose in total). On macOS use
the grid closed it opens what the Tiles button would show with that session added: in the
empty place while the grid has fewer tiles than the count you chose, else in place of the
last tile (never more than the count). On macOS use
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
@@ -89,7 +98,8 @@ keeps the focus.
A moved tile takes the size of the place it lands in: column widths and row heights stay
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
the empty slot included, is saved with the grid and comes back on reload.
the empty slot included, is saved with the grid and comes back when you turn the grid off
and on, and on a page reload while the grid is open.
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
@@ -122,8 +132,18 @@ session finder) shows that session on its own, the normal single view. The grid
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
same. Narrowing the window below the desktop width also returns to the single view.
The grid is saved on this device and comes back when you reload the page, with its focus,
zoom and column widths. A session that was closed in the meantime is simply left out.
The grid is saved in this browser every time you change it (moving, resizing, adding or
removing a tile, changing the count, focusing or zooming a tile), and never sent to the
server. However you leave it (the Tiles button, another tab, Home, a link, closing its last
tile or session), the Tiles button brings it back as it was. A page reload brings it back
when the grid was open; after you left it, a reload shows the single view and the Tiles
button still brings the grid back. A session that was closed or popped out into its own
window in the meantime frees its place for another one, picked the way a new grid picks
them; the place stays empty only when no other session is left. Right after a page reload
the page does not know yet which sessions are waiting for your answer, so that pick goes by
which sessions are working and which you used last. If the window has become too small for
all the tiles, the tile you were in fills the grid until the window is wide enough again,
and the rest of the layout is kept.
Split shows the same logo, name and model above both of its panes.
+28 -7
View File
@@ -14,7 +14,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
@@ -87,9 +87,10 @@ File paths in a session are links. That works in two places:
render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
viewer instead, which follows a file that is still being written.
working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a
page. The exception is a plain text file (a log, `.txt`, `.json`, code) inside the workspace
clicked in the terminal: that opens in the tail viewer instead, which follows a file that is
still being written. A Markdown file always opens rendered, wherever you click it.
Paths **outside** the session's workspace work too, which matters because that is where most
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
@@ -101,8 +102,24 @@ Outside the workspace the allowlist is images, video, audio, PDF, Office documen
files, where "text" is the same list the viewer will let you edit: code, config, logs, csv,
markdown. The reasoning is that a session can already `cat` any of those, so the file suffix
was never what kept anything secret; the path guard is. Types outside the list (`.svg`,
`.bmp`) say so rather than failing silently, and `.html` previews as source rather than being
rendered, so nothing served this way can execute in the page.
`.bmp`) say so rather than failing silently.
An `.html` file opens as a rendered page by default, with its relative CSS, scripts, images and
data files. The page is served from a separate `/html-view/...` address in a sandbox with no
origin of its own, so its scripts can never read Codeman's page, cookies or API.
By the project owner's decision, a rendered page has the same network access as a file you open
from disk in your browser. It **can** load scripts, styles, fonts, images and media from
anywhere on the internet (a dashboard that pulls Chart.js from a CDN works), send requests to
any host, and read the files in its own folder. It **cannot** act as Codeman: it has no access
to Codeman's page, cookies or API, and no access to any folder other than its own. Since a page
can send what it can read to any host, open HTML only from sources you trust, as you would on
your own desktop. The **Page**
pill in the viewer header switches to the source view (where **Edit** works as for any text
file), and the viewer falls back to source on its own when a page cannot be rendered: a remote
(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such
as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it
into a folder of its own). The raw file routes still serve HTML as a download only.
Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a
one-gigabyte log does not try to paint one.
@@ -155,9 +172,13 @@ expose everyone.
Paste from the clipboard or drag and drop straight onto the terminal. The image is written
where the agent can read it and the reference is inserted into your prompt. On a phone, the
image key in the keyboard bar opens the camera or photo library.
toolbar's Attach button opens the camera or photo library, videos included, and puts the path
on your prompt, where it behaves like text you typed; the Image button inside the Compose
editor does the same into the editor. Dropping a video onto the terminal works the same way.
HEIC images from an iPhone are converted to JPEG on the way in.
Videos (mp4, webm, mov, m4v, ogv) are stored as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than through server memory.
## Generated artifacts
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.40.0",
"version": "1.41.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.40.0",
"version": "1.41.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -13389,7 +13389,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.4.0",
"version": "0.4.1",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+5 -4
View File
@@ -1,7 +1,8 @@
{
"name": "aicodeman",
"version": "1.40.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"version": "1.41.1",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -42,7 +43,7 @@
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
"knip": "npx --yes knip@latest --config config/knip.json",
"release": "changeset publish"
"release": "node scripts/npm-release.mjs"
},
"prettier": {
"singleQuote": true,
@@ -171,7 +172,7 @@
"bugs": {
"url": "https://github.com/Ark0N/Codeman/issues"
},
"homepage": "https://github.com/Ark0N/Codeman#readme",
"homepage": "https://getcodeman.com",
"files": [
"dist",
"scripts/postinstall.js",
+7
View File
@@ -133,6 +133,13 @@ its CSP for WebAssembly). Deploy = copy the bundle into Codeman's
drop point. Pinch the panel again to move it anywhere. It stays inside the
camera-owning page, so the hand keeps control (the old `window.app.detachSession`
`window.open` was a one-way trip). A small twitch-and-release cancels.
- **Tiles** (tile grid open): pinch anywhere on a tile and carry it onto
another tile (the two trade places) or an empty cell (it moves there). A
session tab carried onto a tile or an empty cell joins the grid there. With
**Detach Tiles** on (App Settings, per device, off by default) a tile let go
outside the grid opens as a window of its own, which needs the site's pop-ups
allowed (a hand is no click). The moves are tile-grid.js's own
(`tileDropTargetAt` / `dropOnTileTarget` / `detachTileAtPoint`).
- **Run / Run Shell** — pinch over the **Run** (`#runBtn`) or **Run Shell**
(`.btn-shell`) toolbar button and release in place to fire it; drifting too
far first cancels the tap. The button list is `CLICK_SELECTOR` in `entry.ts`.
+182 -6
View File
@@ -27,6 +27,14 @@
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
// in place → fires the button's real click handler. Drift too far first and
// it's treated as a stray move, not a tap.
// • Tile "grab-to-move": with the tile grid open, pinch anywhere on a tile
// and carry it onto another tile (the two trade places) or an empty cell
// (it moves there). With Detach Tiles on (App Settings, per device, off by
// default) a tile let go OUTSIDE the grid opens as a window of its own.
// A session TAB carried onto a tile or an empty cell joins the grid there,
// as a mouse-dragged tab does. Every move goes through app.js's own paths
// (tile-grid.js tileDropTargetAt / dropOnTileTarget / detachTileAtPoint), so
// the hand and the mouse can never disagree about what a drop does.
//
// Why in-page floats, not OS-window detach (decided 2026-06-08, see
// docs/MULTIMONITOR_DESIGN.md): the hand only exists in the one page that owns
@@ -52,11 +60,22 @@ declare global {
saveSubagentWindowStates?: () => void;
subagentWindowZIndex?: number;
ultracodeWindowZIndex?: number;
// The tile grid (tile-grid.js). All optional: an older dashboard without
// them simply has no tile verbs for the hand.
tileAtPoint?: (x: number, y: number) => string | null;
canGrabTile?: (id: string) => boolean;
tileDropTargetAt?: (x: number, y: number, draggedId: string) => TileTarget | null;
dropOnTileTarget?: (draggedId: string, target: TileTarget) => boolean;
isOverTileGrid?: (x: number, y: number) => boolean;
tileDetachEnabled?: () => boolean;
detachTileAtPoint?: (id: string, x: number, y: number, at?: { offsetX: number; offsetY: number }) => boolean;
};
}
}
const TAB_SELECTOR = '.session-tab';
/** A tile of the open tile grid (tile-grid.js), keyed by `data-session-id`. */
const TILE_SELECTOR = '#tileGrid .tile';
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
const PANEL_SELECTOR = '.cg-float';
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
@@ -81,11 +100,20 @@ const FLOAT_H = 420;
const DETACH_PULL_PX = 70;
/** If a button-pinch drifts more than this, it's a stray move, not a tap. */
const TAP_CANCEL_PX = 45;
/** How far (px) a grabbed tile must travel before it targets anything: a pinch
* that barely moves is a stray one, and the tile stays where it is. */
const TILE_MOVE_PX = 40;
/** The width (px) of the small copy of a grabbed tile that follows the hand. */
const TILE_GHOST_W = 280;
/** First letter colours: cyan left, violet right; green while pinching. */
const handColor = (handedness: string, pinching: boolean): string =>
pinching ? '#4ade80' : handedness === 'Right' ? '#a78bfa' : '#38bdf8';
/** Where a session carried by the hand would land in the tile grid: another
* tile, or an empty cell (tile-grid.js tileDropTargetAt). */
type TileTarget = { kind: 'tile'; id: string; el: HTMLElement } | { kind: 'cell'; cell: number; el: HTMLElement };
/** A floating in-page session panel (an iframe of `/session/:id`) the hand can
* place and re-grab. Stays in this page's DOM, so it never leaves hand reach. */
interface FloatingPanel {
@@ -107,6 +135,29 @@ type Grab =
oy: number;
/** Pulled past the float-out threshold at least once. */
armed: boolean;
/** With the tile grid open: the tile or empty cell it would join (highlighted). */
target: TileTarget | null;
}
| {
/** A tile of the open grid, carried to another cell or (Detach Tiles)
* out of the grid. The tile itself never moves until the drop: it dims
* (`tile--dragging`, the mouse drag's own class) and a small copy of
* it follows the hand. */
kind: 'tile';
id: string;
el: HTMLElement;
ghost: HTMLElement;
/** Where in the tile the hand closed, so a pop-out window lands under it. */
offsetX: number;
offsetY: number;
ox: number;
oy: number;
/** Travelled past TILE_MOVE_PX at least once. */
armed: boolean;
/** The tile or empty cell it would drop onto (highlighted). */
target: TileTarget | null;
/** Outside the grid with Detach Tiles on: letting go opens a window. */
out: boolean;
}
| {
kind: 'panel';
@@ -224,7 +275,7 @@ class GestureBridge {
await this.gc.start();
this.running = true;
this.button.classList.add('on');
this.status.textContent = 'on — pinch a tab, window, or button';
this.status.textContent = 'on: pinch a tab, tile, window, or button';
} catch (err) {
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
// (number/string), so `(err as Error).message` was logging "undefined".
@@ -295,6 +346,35 @@ class GestureBridge {
return;
}
// A tile of the open tile grid → carry it to another cell, or (Detach
// Tiles) out of the grid. Anywhere on the tile: the hand is choosing the
// whole tile, as with the agent windows. A tile that can do neither (alone
// or zoomed, Detach Tiles off) is left alone, so the pinch falls through.
const tileId = window.app?.tileAtPoint?.(x, y) ?? null;
const tileEl = tileId ? this.hitClosest(x, y, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add('tile--dragging');
this.grabs.set(hand, {
kind: 'tile',
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y - rect.top, rect.height)),
ox: x,
oy: y,
armed: false,
target: null,
out: false,
});
this.positionGhost(ghost, x, y);
this.status.textContent = 'moving tile';
return;
}
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
const tab = this.hitClosest(x, y, TAB_SELECTOR);
const id = tab?.dataset.id;
@@ -308,7 +388,7 @@ class GestureBridge {
document.body.append(ghost);
tab.classList.add('cg-grabbed');
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false, target: null });
this.positionGhost(ghost, x, y);
return;
}
@@ -325,13 +405,42 @@ class GestureBridge {
private onDrag(hand: string, x: number, y: number): void {
const grab = this.grabs.get(hand);
if (grab?.kind === 'tile') {
this.positionGhost(grab.ghost, x, y);
if (!grab.armed && Math.hypot(x - grab.ox, y - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x, y);
grab.out = out;
grab.ghost.classList.toggle('cg-armed', !!target);
grab.ghost.classList.toggle('cg-out', out);
this.status.textContent = target
? target.kind === 'tile'
? 'release to swap tiles'
: 'release to move the tile here'
: out
? 'release to open in a new window'
: 'moving tile';
return;
}
if (grab?.kind === 'tab') {
this.positionGhost(grab.ghost, x, y);
const pulled = Math.hypot(x - grab.ox, y - grab.oy) >= DETACH_PULL_PX;
if (pulled !== grab.armed) {
// The tile grid open: a tile or an empty cell under the hand takes the
// tab there, as a mouse-dragged tab does.
const target = pulled ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
grab.armed = pulled;
grab.ghost.classList.toggle('cg-armed', pulled);
this.status.textContent = pulled ? 'release to float out' : 'on — pinch a tab';
this.status.textContent = target
? 'release to tile it here'
: pulled
? 'release to float out'
: 'on — pinch a tab';
}
return;
}
@@ -354,17 +463,44 @@ class GestureBridge {
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove('cg-tap-armed');
this.taps.delete(hand);
this.status.textContent = 'on — pinch a tab, window, or button';
this.status.textContent = 'on: pinch a tab, tile, window, or button';
}
}
private onDrop(hand: string, x: number, y: number): void {
const grab = this.grabs.get(hand);
if (grab?.kind === 'tile') {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove('tile--dragging');
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash('cancelled');
return;
}
// Asked again at the drop: the grid may have changed since the last frame.
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? 'moved tile' : 'cancelled');
} else if (grab.out && !app?.isOverTileGrid?.(x, y)) {
// A window opened with no click behind it is what popup blockers stop:
// detachSession then says so in a toast and the tile stays.
const opened = app?.detachTileAtPoint?.(grab.id, x, y, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? 'opened in a new window' : 'pop-out blocked: allow popups for this site');
} else {
this.flash('cancelled');
}
return;
}
if (grab?.kind === 'tab') {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
if (grab.armed) this.floatPanel(grab.id, x, y);
this.setTileTarget(grab, null);
const target = grab.armed ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? 'tiled' : 'cancelled');
else if (grab.armed) this.floatPanel(grab.id, x, y);
else this.flash('cancelled');
return;
}
@@ -505,6 +641,33 @@ class GestureBridge {
ghost.style.top = `${y}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
private tileGhost(tileEl: HTMLElement, rect: DOMRect): HTMLElement {
const ghost = el('div', 'cg-ghost cg-tile-ghost');
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round((width * rect.height) / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector('.tile-header');
if (header) {
const copy = header.cloneNode(true) as HTMLElement;
copy.removeAttribute('draggable');
ghost.append(copy);
}
ghost.append(el('div', 'cg-tile-ghost-body'));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
private setTileTarget(grab: { target: TileTarget | null }, target: TileTarget | null): void {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove('tile--drop-target');
target?.el.classList.add('tile--drop-target');
}
grab.target = target;
}
private cancelAllGrabs(): void {
// Floats themselves persist (they're placed windows) — only release any
// in-progress grab cleanly, restoring a moved panel's interactivity.
@@ -512,6 +675,11 @@ class GestureBridge {
if (grab.kind === 'tab') {
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
this.setTileTarget(grab, null);
} else if (grab.kind === 'tile') {
grab.ghost.remove();
grab.el.classList.remove('tile--dragging');
this.setTileTarget(grab, null);
} else if (grab.kind === 'panel') {
grab.panel.el.style.pointerEvents = '';
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
@@ -525,6 +693,7 @@ class GestureBridge {
document
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t) => t.classList.remove('tile--dragging'));
}
private onStatus(fps: number, hands: HandState[]): void {
@@ -607,6 +776,13 @@ function injectStyles(): void {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+46
View File
@@ -1,5 +1,51 @@
# xterm-zerolag-input
## 0.4.1
### Patch Changes
- 87f1c9c: ### Thanks
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
![Codeman tile grid: six live agents powering on and off with the CRT tile animation](https://raw.githubusercontent.com/Ark0N/Codeman/08694b5862534b2b1224e9e272ad9858c787ebef/release-1.41/tiles-crt-stats-800.gif)
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
**Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
## 0.4.0
### Minor Changes
+1 -1
View File
@@ -537,7 +537,7 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
### Scroll awareness
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
The overlay hides while the cursor row is scrolled out of the viewport and re-renders, debounced, when it scrolls back into view. A viewport parked a few rows above the bottom keeps painting as long as the cursor row is on screen. A buffer that reports no `cursorY` keeps the bottom-only rule.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.4.0",
"version": "0.4.1",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
return '';
}
}
/**
* Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare
* `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above
* the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule.
*/
export function promptRowInViewport(terminal: XtermTerminal): boolean {
try {
const buf = terminal.buffer.active;
if (buf.viewportY === buf.baseY) return true;
if (typeof buf.cursorY !== 'number') return false;
const cursorRow = buf.baseY + buf.cursorY;
return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows;
} catch {
return false;
}
}
@@ -8,7 +8,7 @@ import type {
FontStyle,
} from './types.js';
import { getCellDimensions } from './cell-dimensions.js';
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js';
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
@@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon {
// Cache font properties
this._cacheFont();
// Scroll detection: hide overlay when scrolled away from bottom
// Scroll detection: hide the overlay while the cursor row is scrolled out of view
this._scrollHandler = () => {
try {
const buf = this._terminal!.buffer.active;
if (buf.viewportY !== buf.baseY) {
if (!promptRowInViewport(this._terminal!)) {
this._overlay!.style.display = 'none';
if (this._scrollTimer) {
clearTimeout(this._scrollTimer);
@@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon {
try {
const buf = this._terminal.buffer.active;
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
if (buf.viewportY !== buf.baseY) {
// Hide the overlay while the cursor row is scrolled out of view
if (!promptRowInViewport(this._terminal)) {
this._overlay.style.display = 'none';
return;
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import type { XtermTerminal, PromptFinder } from '../src/types.js';
function term(lines: string[]) {
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
cleanup();
});
});
describe('promptRowInViewport', () => {
const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) =>
({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never;
it('is true at the bottom regardless of the cursor', () => {
expect(promptRowInViewport(term(10, 10, undefined))).toBe(true);
});
it('is true for a viewport parked above the bottom while the cursor row is on screen', () => {
// scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row
expect(promptRowInViewport(term(0, 16, 5))).toBe(true);
// cursor exactly on the last visible row
expect(promptRowInViewport(term(0, 23, 0))).toBe(true);
});
it('is false once the cursor row is scrolled out of the viewport', () => {
expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row
expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history
});
it('keeps the bottom-only rule when the buffer has no cursorY', () => {
expect(promptRowInViewport(term(0, 1, undefined))).toBe(false);
});
});
@@ -725,3 +725,34 @@ describe('ZerolagInputAddon', () => {
});
});
});
describe('viewport scrolled away from the bottom', () => {
function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) {
const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows });
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, mock, overlay };
}
it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => {
// The host parks the viewport to keep trailing blank rows out of view; the
// prompt and cursor are still visible, so the user's text must be too.
const { addon, overlay } = parked(0, 1, 0);
addon.appendText('abc');
expect(addon.pendingText).toBe('abc');
expect(overlay.style.display).not.toBe('none');
expect(overlay.textContent).toContain('abc');
});
it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => {
const { addon, overlay } = parked(0, 30, 0);
addon.appendText('abc');
expect(addon.pendingText).toBe('abc');
expect(overlay.style.display).toBe('none');
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.40.0",
"version": "1.41.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+30 -7
View File
@@ -1,11 +1,13 @@
---
name: codeman
description: >-
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
(wait / wait-output / send-and-wait), 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, watch
Drive Codeman, the session manager this agent is running inside, over its HTTP API,
from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
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
session (CODEMAN_MUX=1); refuse to act otherwise.
---
@@ -29,11 +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
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
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
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.
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
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
@@ -41,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
single most likely way to break a run).
**Codeman seeds the preamble file for you** when it spawns a claude session (server
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
**Codeman seeds the preamble file for you** when it spawns your session (claude since
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:
```bash
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
*
* npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
* @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
* keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
* Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
* `files` cannot help: npm-packlist always includes every root `README.*`.
*
* Renaming the file would break every link to it, so for the length of the publish only it
* moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
* whatever the publish did. The move happens HERE, inside the publish command, never as a
* step before `changesets/action` in release.yml: that action also runs the version path and
* commits the working tree into its version PR, which would commit the deletion.
*
* A previous run killed between the move and the restore leaves the aside copy behind; the
* next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
*
* node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
*/
import { existsSync, renameSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const HIDDEN_README = 'README.zh-CN.md';
export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
/**
* Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
* when `publish` fails or throws. Returns the exit code `publish` returned.
*
* @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
* @returns {number}
*/
export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
const original = join(root, HIDDEN_README);
const aside = join(root, ASIDE_NAME);
if (existsSync(aside) && !existsSync(original)) {
renameSync(aside, original);
log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
}
const moved = existsSync(original);
if (moved) {
renameSync(original, aside);
log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
}
try {
return publish();
} finally {
if (moved) {
renameSync(aside, original);
log(`npm-release: ${HIDDEN_README} restored`);
}
}
}
function runChangesetPublish() {
const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
if (result.error) {
console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
return 1;
}
return result.status ?? 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
}
+18
View File
@@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman';
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json'];
// The MCP registry manifest for `codeman mcp` (published with `mcp-publisher` from its
// directory): the server and its npm package both carry the release version.
const MCP_REGISTRY_MANIFEST = 'config/mcp-registry/server.json';
const check = process.argv.includes('--check');
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
@@ -78,6 +81,21 @@ for (const file of MANIFESTS) {
if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
}
// 3. The MCP registry manifest.
{
const json = JSON.parse(readFileSync(MCP_REGISTRY_MANIFEST, 'utf8'));
const targets = [json, ...(json.packages ?? []).filter((p) => p.registryType === 'npm')];
let changed = false;
for (const target of targets) {
if (target.version !== version) {
drift.push(`${MCP_REGISTRY_MANIFEST}: ${target.version} -> ${version}`);
target.version = version;
changed = true;
}
}
if (changed && !check) writeFileSync(MCP_REGISTRY_MANIFEST, JSON.stringify(json, null, 2) + '\n');
}
if (drift.length === 0) {
console.log(`plugin in step: mirror identical, manifests at ${version}`);
} else if (check) {
+30 -7
View File
@@ -1,11 +1,13 @@
---
name: codeman
description: >-
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
(wait / wait-output / send-and-wait), 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, watch
Drive Codeman, the session manager this agent is running inside, over its HTTP API,
from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
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
session (CODEMAN_MUX=1); refuse to act otherwise.
---
@@ -29,11 +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
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
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
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.
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
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
@@ -41,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
single most likely way to break a run).
**Codeman seeds the preamble file for you** when it spawns a claude session (server
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
**Codeman seeds the preamble file for you** when it spawns your session (claude since
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:
```bash
+15 -3
View File
@@ -9,7 +9,8 @@
import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { basename, extname, isAbsolute, join } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js';
@@ -148,6 +149,11 @@ class AttachmentRegistry {
export const attachmentRegistry = new AttachmentRegistry();
/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
export function supportedAttachmentExtensions(): string[] {
return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
}
export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
}
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) {
// `~/…` is the home of the session's HOST: expanded here for a local session,
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
// whose home this server does not know. A clicked terminal link sends it as is
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
// resolved absolute path either way.
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
: resolveLocalAttachment(requestedPath));
: resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
+985
View File
@@ -0,0 +1,985 @@
/**
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
* inside a Codeman session, in every CLI mode.
*
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
* preamble — has the same verbs from one line in its AGENTS.md.
*
* Invariants (each asserted in `test/cli-agent.test.ts`):
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
* never guesses a URL — a server you are not part of is not yours to drive.
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
* `interrupt`, which never appends `\r`. A stray control byte is a dead session
* in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
* direction refuses. Ids appear in full and 8-char form, so equality alone
* misses a real combination — and the miss deletes the caller.
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
* verb. `9` would resolve to whichever session is alone with that first
* character, the user's own interactive tab included.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
*
* @module cli-agent
*/
import http from 'node:http';
import https from 'node:https';
import type { Command } from 'commander';
import {
basicAuthHeader,
credentialsFrom,
readCodemanEnvFile,
type CodemanCredentials,
} from './codeman-credentials.js';
import { getCli } from './config/cli-registry/registry.js';
import { GLYPH, palette, table } from './cli-style.js';
import { getErrorMessage } from './types.js';
import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
// ─────────────────────────────────────────────────────────────────────────────
// Context and guard
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentContext {
/** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
apiUrl: string;
/** This session's id, from `CODEMAN_SESSION_ID`. */
selfId: string;
/** Basic-auth credentials, when the server has a password. */
auth?: CodemanCredentials;
}
/** Thrown when the process is not inside a Codeman-managed session. */
export class AgentGuardError extends Error {}
/** Exit codes shared by every verb; a shell agent can branch on them. */
export const EXIT = {
ok: 0,
error: 1,
timeout: 2,
dead: 3,
refused: 4,
} as const;
/**
* Resolve the context from the environment, or throw `AgentGuardError`.
*
* Credentials in the order every client of the API uses (`credentialsFrom`, shared
* with `codeman attach` and the TUI): each field from the environment (a session
* inherits the server's), then the data dir's `.env`. No password means the server
* is open (single-user) — or it is not, and the 401 says so.
*/
export function resolveAgentContext(
env: NodeJS.ProcessEnv = process.env,
envFile: () => Record<string, string> = readCodemanEnvFile
): AgentContext {
if (env.CODEMAN_MUX !== '1') {
throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
}
const apiUrl = env.CODEMAN_API_URL?.trim();
if (!apiUrl) {
throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
}
const selfId = env.CODEMAN_SESSION_ID?.trim();
if (!selfId) {
throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
}
const credentials = credentialsFrom(env, envFile());
return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
}
// ─────────────────────────────────────────────────────────────────────────────
// Pure helpers (the invariants)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Is `id` this session? Prefix in BOTH directions, because ids appear in full and
* in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
* shorter than 8 characters cannot prove anything and is treated as "maybe me".
*/
export function isSelfSession(selfId: string, id: string): boolean {
if (!id || selfId.length < 8) return true;
return id.startsWith(selfId) || selfId.startsWith(id);
}
/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
export function deleteRefusal(selfId: string, id: string): string | undefined {
if (!id) return 'refusing: empty session id';
if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
return undefined;
}
/**
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
* back into a single line, so the multi-line refusal below would never see it.
*/
export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return {
error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send <id> "…"\`; a prompt that starts with "-" goes after --: \`send <id> -- "- fix the bug"\`)`,
};
}
/**
* Why `send` refuses this text, or `undefined` when it is printable. The composer
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
* DEL) reaches the pane as a keypress. None of that is a prompt.
*/
export function inputRefusal(text: string): string | undefined {
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
// The composer is one line: the server strips newlines, which silently joins the
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
if (/[\n\r\t]/.test(text)) {
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
}
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
// eslint-disable-next-line no-control-regex
const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
if (control) {
const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
}
return undefined;
}
/**
* Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
* caller asked to type without submitting. Never anything else.
*/
export function buildSendBody(
text: string,
options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
): Record<string, unknown> {
const body: Record<string, unknown> = {
input: options.enter ? `${text}\r` : text,
useMux: true,
clientId: options.clientId,
seq: options.seq,
};
if (options.wait !== undefined) body.wait = options.wait;
if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout;
return body;
}
/** Body for the interrupt path: a bare ESC, and nothing appended — ever. */
export function buildInterruptBody(clientId: string, seq: number): Record<string, unknown> {
return { input: '\u001b', useMux: true, clientId, seq };
}
/** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */
export function defaultClientId(selfId: string, suffix = ''): string {
return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
}
/**
* Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
* titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
*/
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${flag} must be a positive integer, got "${raw}"`);
}
return n;
}
/**
* Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
* timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
* during `--until stop` comes back as `ended:true, signal:null` (the registry only
* satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
* a dead worker as `ended:true, matched:false` — both are "dead", never "done".
*/
export function waitExitCode(wait: WaitResult | undefined): number {
if (!wait) return EXIT.error;
if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
if (wait.timedOut) return EXIT.timeout;
if (wait.matched === false) return EXIT.timeout;
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// HTTP
// ─────────────────────────────────────────────────────────────────────────────
export interface ApiEnvelope<T = unknown> {
success: boolean;
data?: T;
error?: string;
errorCode?: string;
}
export interface ApiResponse<T = unknown> {
status: number;
/** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
json?: ApiEnvelope<T>;
text: string;
}
export interface WaitResult {
/** The signal that fired, or null when the wait ended without one. */
signal?: string | null;
timedOut?: boolean;
/** The session went away (deleted / torn down / the write failed) before the wait resolved. */
ended?: boolean;
timeoutMs?: number;
until?: string[];
matched?: boolean;
match?: string;
snippet?: string;
immediate?: boolean;
}
export interface RequestOptions {
method: 'GET' | 'POST' | 'DELETE';
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: Record<string, unknown>;
headers?: Record<string, string>;
/** Socket timeout; long-polls pass their own timeout plus headroom. */
timeoutMs?: number;
/** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */
signal?: AbortSignal;
}
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
/** Every request carries these; they are ignored on endpoints that do not read them. */
export function baseHeaders(ctx: AgentContext): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' };
// Tags sessions this caller spawns as its children (lineage in the web UI).
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
// directory as deletable agent scratch, so it rides only the spawn request that
// may create one (see agentSpawn), never anything else. Absent for a caller outside
// any session (`codeman mcp` launched by a desktop MCP client), which has no self.
if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId;
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
if (authorization) headers.Authorization = authorization;
return headers;
}
/** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */
export const httpRequest: ApiRequest = (ctx, options) => {
const url = new URL(options.path, ctx.apiUrl);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers: Record<string, string | number> = { ...baseHeaders(ctx), ...(options.headers ?? {}) };
if (bodyText !== undefined) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(bodyText);
}
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: options.method,
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
timeout: options.timeoutMs ?? 30_000,
signal: options.signal,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
// A connection dropped mid-body emits 'error' and never 'end'; without this the
// promise never settles (and a long-lived `codeman mcp` never answers the call).
res.on('error', reject);
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8');
let json: ApiEnvelope | undefined;
try {
json = JSON.parse(text) as ApiEnvelope;
} catch {
json = undefined;
}
resolve({ status: res.statusCode ?? 0, json, text });
});
}
);
req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`)));
req.on('error', reject);
if (bodyText !== undefined) req.write(bodyText);
req.end();
});
};
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
export function describeFailure(res: ApiResponse): string {
if (res.json && !res.json.success) {
return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`;
}
const text = res.text.trim().split('\n')[0] ?? '';
if (res.status === 401)
return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`;
return `HTTP ${res.status}${text ? ` ${text}` : ''}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentIo {
out: (line: string) => void;
err: (line: string) => void;
}
export interface AgentDeps {
ctx: AgentContext;
request: ApiRequest;
io: AgentIo;
json: boolean;
/** Clock for `seq`; injectable so tests are deterministic. */
now?: () => number;
}
/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
function emitJson(deps: AgentDeps, data: unknown): void {
deps.io.out(JSON.stringify(data, null, 2));
}
function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number {
if (deps.json) {
deps.io.out(JSON.stringify({ success: false, error: message }));
} else {
deps.io.err(palette.err(`${GLYPH.fail} ${message}`));
}
return code;
}
interface SessionRow {
id: string;
name?: string;
mode?: string;
status?: string;
workingDir?: string;
pid?: number | null;
parentSessionId?: string | null;
}
/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
const FULL_ID_LENGTH = 36;
/**
* Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
* the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
*/
export const MIN_ID_PREFIX_LENGTH = 8;
/**
* Turn the id a human typed into the one the routes accept. `ls` prints 8-char
* prefixes and the routes answer 404 to those (measured live), so anything shorter
* than a full id resolves through the session list; an ambiguous prefix refuses
* rather than picking one. Below 8 characters it refuses before the list: "unique"
* means nothing for `9` — it names whatever session happens to be alone with that
* first character, and `rm`/`send` would act on it.
*/
export async function resolveSessionId(
deps: AgentDeps,
id: string
): Promise<{ id: string } | { error: string; code: number }> {
if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
if (id.length < MIN_ID_PREFIX_LENGTH) {
return {
error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
code: EXIT.refused,
};
}
if (id.length >= FULL_ID_LENGTH) return { id };
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
if (matches.length === 1) return { id: matches[0].id };
if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
export async function agentLs(deps: AgentDeps): Promise<number> {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return fail(deps, describeFailure(res));
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
if (deps.json) {
emitJson(
deps,
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
);
return EXIT.ok;
}
if (sessions.length === 0) {
deps.io.out(palette.muted('(no sessions)'));
return EXIT.ok;
}
const rows = sessions.map((s) => [
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
s.id.slice(0, 8),
s.mode ?? '?',
s.status ?? '?',
s.name || s.workingDir || '',
]);
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
deps.io.out(
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
);
return EXIT.ok;
}
export interface SpawnOptions {
caseName: string;
mode: string;
name?: string;
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
ready: boolean;
timeoutMs: number;
}
/**
* Agent-scratch label for a case directory a spawn CREATES (the server applies it only
* when quick-start makes the directory). The Add Case UI offers a recursive delete for
* such directories, so this header must never ride any other request: mislabelling a
* real repo there is the one failure in this area that costs actual work.
*/
export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
/**
* What the mode's TUI draws once its composer can take a prompt, from the CLI registry
* (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
*/
export function composerReadyMark(mode: string): string | undefined {
return getCli(mode)?.capabilities.composerReadyMark;
}
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> {
const body: Record<string, unknown> = {
caseName: options.caseName,
mode: options.mode,
parentSessionId: deps.ctx.selfId,
};
if (options.name) body.sessionName = options.name;
const res = await deps.request(deps.ctx, {
method: 'POST',
path: '/api/v1/quick-start',
body,
headers: { ...AGENT_ORIGIN_HEADER },
});
const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
const sid = data.sessionId;
let ready: boolean | undefined;
let readinessError: string | undefined;
let dead = false;
const mark = composerReadyMark(options.mode);
if (options.ready && mark) {
const wait = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
// composer never showed up": report the real reason instead of the trust-dialog hint.
if (!wait.json?.success) readinessError = describeFailure(wait);
else {
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
// A worker that died while we waited is exit 3 like every other wait, not a
// "composer not seen" timeout that sends the caller looking for a dialog.
dead = waitExitCode(result) === EXIT.dead;
ready = !dead && Boolean(result?.matched);
}
}
if (deps.json) {
emitJson(deps, { ...data, ready, readinessError });
} else {
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
const say = (line: string) => deps.io.err(line);
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
if (ready === false && !dead) {
say(
palette.warn(
`${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
)
);
}
if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
if (ready === undefined && !readinessError && options.ready) {
say(
palette.muted(
` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
)
);
}
deps.io.out(sid);
}
if (readinessError) return EXIT.error;
if (dead) return EXIT.dead;
return ready === false ? EXIT.timeout : EXIT.ok;
}
export interface SendOptions {
id: string;
text: string;
enter: boolean;
/** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
wait?: string | true;
timeoutMs?: number;
clientId?: string;
seq?: number;
}
/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) {
return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused);
}
const refusal = inputRefusal(options.text);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildSendBody(options.text, {
enter: options.enter,
clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
seq: options.seq ?? (deps.now ?? Date.now)(),
wait: options.wait,
waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
});
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
| undefined;
if (deps.json) emitJson(deps, data ?? {});
// Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
// holds the chunk and types it once the pane is back (`buffered`), or the chunk was
// over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
// so a retry needs a new one (the default, the clock, gives it that).
if (data?.dropped) {
if (!deps.json) {
deps.io.err(
palette.err(
`${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
)
);
}
return EXIT.error;
}
// `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
// the PTY exited or send-keys hit a dead pane. The field exists so a client does not
// say "wait longer" when the truth is "restart the worker" — so it is a failure here.
if (data?.delivered === false && !data.duplicate) {
if (!deps.json) {
deps.io.err(
palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
);
}
return EXIT.dead;
}
if (!deps.json) {
const noEnter = options.enter ? '' : ' (no Enter)';
if (data?.duplicate) {
deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
} else if (data?.buffered) {
deps.io.out(
palette.ok(
`${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
)
);
} else if (data?.delivered === true) {
deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
} else {
// Fire-and-forget answers before the write, so there is no delivery report here.
deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
}
if (data?.wait) deps.io.out(describeWait(data.wait));
}
if (options.wait === undefined) return EXIT.ok;
return waitExitCode(data?.wait);
}
function describeWait(wait: WaitResult): string {
if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
if (wait.ended) {
return palette.err(
`${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
);
}
// A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
// so the line stays neutral instead of looking like an error to whoever reads the log.
if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
if (wait.matched !== undefined) {
return wait.matched
? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
: palette.muted('not matched (exit 2)');
}
return palette.ok(
`${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
);
}
export interface WaitOptions {
id: string;
until?: string;
match?: string;
from?: 'buffer' | 'now';
fresh?: boolean;
nocase?: boolean;
timeoutMs: number;
}
/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<number> {
if (options.until && options.match)
return fail(deps, 'use either --until <signals> or --match <marker>, not both', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
const res = options.match
? await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait-output`,
query: {
match: options.match,
from: options.from ?? 'buffer',
nocase: options.nocase ? 1 : undefined,
timeout: options.timeoutMs,
},
timeoutMs: options.timeoutMs + 10_000,
})
: await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait`,
query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A 400 here is the server saying "this mode has no such signal" (until=stop on an
// external CLI). Passed through, never papered over: the marker path is the answer.
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
if (deps.json) {
emitJson(deps, data ?? {});
} else if (data?.wait) {
deps.io.out(describeWait(data.wait));
if (data.limitPaused)
deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
}
return waitExitCode(data?.wait);
}
export interface ReadOptions {
id: string;
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
tail?: number;
/** Whole conversation (`context=full`) instead of the last assistant message. */
full?: boolean;
}
/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
if (options.tail !== undefined) {
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/terminal`,
query: { tail: options.tail },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
if (deps.json) emitJson(deps, res.json.data);
else deps.io.out(stripAnsi(buffer));
return EXIT.ok;
}
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/last-response`,
query: { context: options.full ? 'full' : undefined },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
| undefined;
if (deps.json) {
emitJson(deps, data ?? {});
return EXIT.ok;
}
if (options.full && data?.messages) {
for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
return EXIT.ok;
}
const text = data?.text ?? '';
if (!text) {
deps.io.err(
palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
);
return EXIT.ok;
}
deps.io.out(text);
return EXIT.ok;
}
/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else
deps.io.out(
palette.ok(
`${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
)
);
return EXIT.ok;
}
/** `agent rm` — delete a session that is provably not this one. */
export async function agentRm(deps: AgentDeps, options: { id: string }): Promise<number> {
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
// to me only if the list is lying, but a delete is the one call worth the paranoia.
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
const res = await deps.request(deps.ctx, {
method: 'DELETE',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commander wiring
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_WAIT_MS = 60_000;
/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
function liveDeps(json: boolean): AgentDeps | undefined {
try {
return {
ctx: resolveAgentContext(),
request: httpRequest,
json,
io: { out: (line) => console.log(line), err: (line) => console.error(line) },
};
} catch (err) {
if (err instanceof AgentGuardError) {
console.error(palette.err(`${GLYPH.fail} ${err.message}`));
return undefined;
}
throw err;
}
}
/** Run a verb with the live transport and turn its exit code into the process exit. */
async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): Promise<void> {
const deps = liveDeps(json);
if (!deps) {
process.exitCode = EXIT.refused;
return;
}
try {
process.exitCode = await verb(deps);
} catch (err) {
console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
process.exitCode = EXIT.error;
}
}
/** Register `codeman agent …` on the program. */
export function registerAgentCommands(program: Command): Command {
const agent = program
.command('agent')
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
agent
.command('ls')
.alias('list')
.description('List sessions; * marks this one')
.option('--json', 'Machine-readable output')
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
agent
.command('spawn <case>')
.description(
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
)
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
.option('-n, --name <name>', 'Session name shown in the UI')
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
.option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentSpawn(deps, {
caseName,
mode: options.mode,
name: options.name,
ready: options.ready,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('send <id> <text...>')
.description(
'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send <id> -- "- fix the bug")'
)
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
.option('-u, --until <signals>', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
.option('--no-enter', 'Type the text without submitting it')
.option('--client-id <id>', 'Exactly-once tag (default: one per calling session)')
.option(
'--seq <n>',
'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
)
.option('--json', 'Machine-readable output')
.action(
(
id: string,
words: string[],
options: {
wait?: boolean;
until?: string;
timeout?: string;
enter: boolean;
clientId?: string;
seq?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) => {
const prompt = sendPromptFromArgs(words);
if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
return agentSend(deps, {
id,
text: prompt.text,
enter: options.enter,
wait: options.until ?? (options.wait ? true : undefined),
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
clientId: options.clientId,
seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
});
})
);
agent
.command('wait <id>')
.description(
'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
)
.option(
'-m, --match <marker>',
'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
)
.option('--from <where>', 'buffer (scan existing output first, the default) or now', 'buffer')
.option('--nocase', 'Case-insensitive --match')
.option('--fresh', 'Require an actual transition (--until only)')
.option('-t, --timeout <ms>', 'Budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(
id: string,
options: {
until?: string;
match?: string;
from: string;
nocase?: boolean;
fresh?: boolean;
timeout?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) =>
agentWait(deps, {
id,
until: options.until,
match: options.match,
from: options.from === 'now' ? 'now' : 'buffer',
nocase: options.nocase,
fresh: options.fresh,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('read <id>')
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
.option('--tail <bytes>', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)')
.option('--full', 'The whole conversation instead of the last assistant message')
.option('--json', 'Machine-readable output')
.action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentRead(deps, {
id,
tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'),
full: options.full,
})
)
);
agent
.command('interrupt <id>')
.description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
);
agent
.command('rm <id>')
.description('Delete any session except this one (refuses your own id)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
return agent;
}
+131 -41
View File
@@ -15,9 +15,21 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.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 { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -42,32 +54,8 @@ function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const { username, password } = readCodemanCredentials();
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
@@ -162,7 +150,8 @@ export function resolveCliCasePath(name: string): string {
/**
* Resolve where `skill install` / `skill uninstall` operate. Global is
* `~/.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
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
* and stays single-user.
@@ -173,17 +162,18 @@ export function resolveCliCasePath(name: string): string {
*/
export function resolveSkillTargetPath(options: {
case?: string;
skillDir?: AgentSkillDir;
}): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } {
if (options.case) {
const casePath = resolveCliCasePath(options.case);
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') };
}
/** 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);
if (resolved.missingCase !== undefined) {
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
@@ -192,8 +182,44 @@ function resolveSkillTarget(options: { case?: string }): string {
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 }> = {
installed: { ok: true, text: `Agent skill installed: ${target}` },
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
@@ -209,7 +235,12 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void
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) {
console.log(palette.ok(`✓ ${message.text}`));
} else {
@@ -227,9 +258,19 @@ skillCmd
.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('-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 {
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);
} catch (err) {
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
@@ -239,19 +280,70 @@ skillCmd
skillCmd
.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('-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 }) => {
try {
const target = resolveSkillTarget(options);
reportSkillResult(await removeAgentSkillFrom(target), target);
if (!options.case) {
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) {
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
process.exit(1);
}
});
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands(program);
// ============ MCP server (any MCP client drives Codeman) ============
program
.command('mcp')
.description(
'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions'
)
.option('--url <url>', 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)')
.option(
'--allow-any-session',
'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)'
)
.action(async (options: { url?: string; allowAnySession?: boolean }) => {
await runMcpStdio(
resolveMcpConfig({ url: options.url, allowAnySession: options.allowAnySession, version: pkg.version })
);
process.exit(0);
});
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -641,9 +733,7 @@ function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
} catch {
return Promise.resolve(null);
}
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const { username, password } = readCodemanCredentials();
const transport = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = { Accept: 'application/json' };
if (password) {
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Credentials for a client of this Codeman instance's own API.
*
* Env first, the data dir's `.env` as the fallback — the hand-authored file
* `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
* three clients cannot drift on quoting, comments or the default username.
*
* @module codeman-credentials
*/
import { readFileSync } from 'node:fs';
import { dataPath } from './config/instance.js';
export interface CodemanCredentials {
username: string;
/** Absent when no password is configured (or only the server's environment has it). */
password?: string;
}
/**
* Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
* prefix tolerated (the file is hand-authored, often sourced by a shell too), one
* layer of matching quotes stripped, anything that is not an assignment ignored.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record<string, string> {
try {
return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
return {};
}
}
/**
* The lookup order every client uses, per field: the environment, then the `.env`
* file, then (username only) `admin`. Pure, so a caller with its own environment
* object (`codeman agent`'s guard takes one for testability) gets the same answer.
*/
export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record<string, string>): CodemanCredentials {
const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
/**
* Credentials for the API. No password means no auth is configured, or the user has
* it only in the server's environment, in which case the API answers 401.
*/
export function readCodemanCredentials(
envFilePath: string = dataPath('.env'),
env: NodeJS.ProcessEnv = process.env
): CodemanCredentials {
return credentialsFrom(env, readCodemanEnvFile(envFilePath));
}
/** `Authorization` header value, or undefined when there is no password to send. */
export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
+10
View File
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
*/
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
/**
* Maximum size (bytes) of a single video uploaded via POST
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
* offers the camera roll's videos beside its photos). Videos stream to disk
* rather than through memory, so this is a disk-fill sanity bound, not a memory
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
*/
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
// ============================================================================
// File Download Limits
// ============================================================================
+17 -1
View File
@@ -15,7 +15,10 @@
import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.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. */
const cliId = z
@@ -294,6 +297,10 @@ const capabilitiesSchema = z
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
@@ -302,6 +309,9 @@ const capabilitiesSchema = z
respawn: z.boolean(),
effort: 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(),
// 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
@@ -320,6 +330,8 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
// Literal text matched by a `wait-output` long-poll, never compiled as a regex.
composerReadyMark: z.string().min(1).max(64).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -441,6 +453,9 @@ const capabilitiesSchema = z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Home-relative fallback used when `path` is absent. Same no-traversal rule as `path`:
// sync reads it and, when it parses as plain JSON, writes to it.
altPath: mcpRelativePath.optional(),
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
@@ -449,6 +464,7 @@ const capabilitiesSchema = z
'codex-toml',
'opencode-json',
'antigravity-json',
'copilot-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
+48
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
// 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
@@ -218,6 +233,9 @@ const CLAUDE: CliEntry = {
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
// The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
// trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
composerReadyMark: 'shift+tab',
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
@@ -267,6 +285,10 @@ const CLAUDE: CliEntry = {
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
@@ -275,6 +297,7 @@ const CLAUDE: CliEntry = {
respawn: true,
effort: true,
agentSkillInjection: true,
agentSkillDir: '.claude/skills',
statusLineTelemetry: true,
model: { source: 'claude-settings-file' },
privilegedParams: [],
@@ -491,8 +514,14 @@ const OPENCODE: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores
// SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux
// `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the
// wheel is turned into page keys or it does nothing at all (#555).
transcriptPageKeys: true,
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
@@ -533,6 +562,7 @@ const OPENCODE: CliEntry = {
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
altPath: '.config/opencode/opencode.jsonc',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
@@ -624,6 +654,7 @@ const CODEX: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// 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
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
@@ -703,6 +734,11 @@ const CODEX: CliEntry = {
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
// This is the page-key fallback for a hollow pane, not wheel forwarding.
transcriptPageKeys: true,
maxFrameBytes: 32 * 1024,
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so
// the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
@@ -813,6 +849,7 @@ const GEMINI: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
@@ -921,6 +958,7 @@ const ANTIGRAVITY: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
@@ -1016,6 +1054,11 @@ const PI: CliEntry = {
},
capabilities: {
...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
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
@@ -1166,6 +1209,7 @@ const GROK: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// 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
// erase-scrollback+DECSET strip.
@@ -1338,10 +1382,13 @@ const DEEPSEEK: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// Definitive rather than inferred: the harness TUI reports idle/working/blocked to a
// 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.
hooks: 'supervised',
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
composerReadyMark: '❯',
transcript: 'deepseek-zstd',
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
@@ -1501,6 +1548,7 @@ const OMP: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
altScreen: 'strip-mux-only',
+59 -2
View File
@@ -69,6 +69,14 @@ export type Cond =
*/
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. */
export type ArgSpec =
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
@@ -105,7 +113,13 @@ export type ModelConfigResolverName = 'deepseek-route';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
export type McpConfigFormat =
| 'claude-json'
| 'gemini-json'
| 'codex-toml'
| 'opencode-json'
| 'antigravity-json'
| 'copilot-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
@@ -416,6 +430,17 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
/**
* Literal text the TUI draws once its composer can take a prompt — what `codeman agent
* spawn` waits for (a `wait-output` match) before it calls a worker ready.
*
* Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
* of its workspace-trust dialog, which is exactly the screen a readiness wait must not
* mistake for a composer, so claude declares its composer's own hint text instead.
* Absent means no readiness wait: a spawn returns as soon as the session exists, and
* the caller synchronizes on `wait-output` markers.
*/
composerReadyMark?: string;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
@@ -490,6 +515,20 @@ export interface CliCapabilities {
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
*
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
*
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
@@ -498,7 +537,20 @@ export interface CliCapabilities {
ralph: boolean;
respawn: 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;
/**
* 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;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
@@ -627,7 +679,12 @@ export interface CliCapabilities {
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
mcpConfig?: {
path: string;
altPath?: string;
format: McpConfigFormat;
relocation?: { envVar: string; path: string };
};
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+284
View File
@@ -0,0 +1,284 @@
/**
* @fileoverview Folder listings for the file-preview folder grid.
*
* A folder the user opens (a clicked folder path, or "Folder" in the file
* preview) is resolved, checked against the same attachment guard a single file
* goes through, and GRANTED an id. The grid then asks for files by
* `folderId + file name` only, so browser requests never carry arbitrary paths —
* the rule the attachment routes follow for single files.
*
* Grants live in their own small registry, not in the attachment registry: that one
* is capped at 200 records per session, and a folder of 300 renders would evict the
* session's real attachments.
*
* Listings contain non-hidden subfolders and files whose type the preview supports
* (`isSupportedAttachmentExtension`), newest first, capped at
* {@link MAX_FOLDER_ENTRIES}. Local folders are read with `fs`; a remote (SSH) case's
* folder is listed on its host in one ssh round trip (`remoteListFolder`).
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { extname, isAbsolute, join, posix } from 'node:path';
import {
getAttachmentType,
isSupportedAttachmentExtension,
supportedAttachmentExtensions,
} from './attachment-registry.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from './config/multiuser.js';
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
import type { AttachmentDetectedType, AuthUser } from './types.js';
import type { SessionRemote } from './types/session.js';
import { describeUnknownPath, probePathKind } from './utils/bounded-path-probe.js';
import { validateSessionFilePath } from './web/route-helpers.js';
/** Most entries (files + subfolders) one listing returns. */
export const MAX_FOLDER_ENTRIES = 1000;
/** Folder grants kept per session; the oldest is dropped beyond this. */
const MAX_FOLDER_GRANTS_PER_SESSION = 32;
export interface FolderFileEntry {
name: string;
extension: string;
attachmentType: AttachmentDetectedType;
size: number;
mtimeMs: number;
}
export interface FolderListing {
folderId: string;
/** The folder with symlinks resolved (on the session's host). */
path: string;
files: FolderFileEntry[];
/** Non-hidden subfolder names, sorted. */
folders: string[];
truncated: boolean;
}
export class FolderListingError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
}
}
interface FolderGrant {
folderId: string;
dir: string;
}
class FolderGrants {
private bySession = new Map<string, Map<string, FolderGrant>>();
grant(sessionId: string, dir: string): string {
let grants = this.bySession.get(sessionId);
if (!grants) {
grants = new Map();
this.bySession.set(sessionId, grants);
}
for (const existing of grants.values()) {
if (existing.dir === dir) {
// Refresh its position so an often-used folder is the last to be dropped.
grants.delete(existing.folderId);
grants.set(existing.folderId, existing);
return existing.folderId;
}
}
const folderId = `fld_${randomUUID()}`;
grants.set(folderId, { folderId, dir });
while (grants.size > MAX_FOLDER_GRANTS_PER_SESSION) {
const oldest = grants.keys().next().value;
if (oldest === undefined) break;
grants.delete(oldest);
}
return folderId;
}
get(sessionId: string, folderId: string): FolderGrant | undefined {
return this.bySession.get(sessionId)?.get(folderId);
}
clearSession(sessionId: string): void {
this.bySession.delete(sessionId);
}
}
export const folderGrants = new FolderGrants();
/**
* A file name inside a granted folder, or null when it is not one: no path
* separator, not hidden (which also rules out `.`/`..`; the listing leaves hidden
* entries out, so the routes do too), no NUL, and a type the preview supports.
*/
export function validFolderFileName(name: unknown): string | null {
if (typeof name !== 'string' || !name || name.length > 255) return null;
if (name.startsWith('.') || name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
const extension = extname(name).toLowerCase().replace(/^\./, '');
return isSupportedAttachmentExtension(extension) ? name : null;
}
/** Where a granted folder's file lives (on the session's host). */
export function folderFilePath(dir: string, name: string): string {
return posix.join(dir, name);
}
interface ListFolderOptions {
sessionWorkingDir?: string;
remote?: SessionRemote;
/** The requesting identity; in multi-user mode a regular user is held to {@link folderAllowedForUser}. */
user?: AuthUser;
}
/**
* Multi-user rule for folder grants, the same one the filesystem path picker follows:
* a regular user reaches only their own space (`userSpacePath`) or the session's
* `workingDir`. User spaces live inside `homedir()`, so without it a user could list
* and read another user's space. Always true in single-user mode and for admins; a
* remote session's folders are on its host, where only the `workingDir` applies.
*
* @param path Already resolved (realpath on the session's host).
*/
export function folderAllowedForUser(
path: string,
user: AuthUser | undefined,
scope: { workingDir?: string; remote?: SessionRemote }
): boolean {
if (!isMultiUserMode()) return true;
if (!user) return false;
if (user.role === 'admin') return true;
if (scope.remote) return !!scope.workingDir && isUnderTree(path, scope.workingDir);
return [userSpacePath(user.username), scope.workingDir].some(
(root) => !!root && !!validateSessionFilePath(root, path)
);
}
/**
* Resolve, guard, list and grant a folder for `sessionId`.
*
* A local folder is probed bounded first (`probePathKind`): one on a network mount
* that stopped answering must not freeze the server, and "did not answer" is never
* reported as "not found". The guard runs on the resolved folder before it is read.
*
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked or outside the
* user's space, 404 missing, 502 remote unreachable, 503 local folder not answering.
*/
export async function listFolder(
sessionId: string,
requestedPath: string,
options: ListFolderOptions = {}
): Promise<FolderListing> {
const homeRelative = typeof requestedPath === 'string' && (requestedPath === '~' || requestedPath.startsWith('~/'));
if (!requestedPath || typeof requestedPath !== 'string' || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new FolderListingError('Folder path must be absolute');
}
const guard = await loadAttachmentGuardConfig();
const workingDir = options.sessionWorkingDir;
const assertAllowed = (dir: string): void => {
const confined = !guard.confineToWorkspace
? true
: options.remote
? !!workingDir && isUnderTree(dir, workingDir)
: !!workingDir && !!validateSessionFilePath(workingDir, dir);
if (!confined || isBlockedAttachmentPath(dir, guard.blockedTrees)) {
throw new FolderListingError('Access to this folder is blocked', 403);
}
if (!folderAllowedForUser(dir, options.user, { workingDir, remote: options.remote })) {
throw new FolderListingError('Folder is outside your space', 403);
}
};
let dir: string;
let entries: RemoteFolderEntry[];
let truncated = false;
if (options.remote) {
const remotePath = requestedPath === '~' ? '~/' : requestedPath;
let listing;
try {
listing = await remoteListFolder(options.remote, remotePath, supportedAttachmentExtensions(), MAX_FOLDER_ENTRIES);
} catch (err) {
throw new FolderListingError(err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable', 502);
}
if (!listing) throw new FolderListingError('Folder not found', 404);
if (listing === 'not-a-folder') throw new FolderListingError('Not a folder');
({ dir, entries, truncated } = listing);
// One ssh call lists and resolves together, so the guard can only run after it;
// nothing of a refused listing leaves this function.
assertAllowed(dir);
} else {
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
const kind = await probePathKind(expanded, { pastCap: true });
if (kind === 'unknown') {
throw new FolderListingError(describeUnknownPath('Folder', expanded, { pastCap: true }), 503);
}
if (kind === 'absent') throw new FolderListingError('Folder not found', 404);
if (kind !== 'directory') throw new FolderListingError('Not a folder');
try {
dir = await fs.realpath(expanded);
} catch {
throw new FolderListingError('Folder not found', 404);
}
assertAllowed(dir);
({ entries, truncated } = await listLocalFolder(dir));
}
const files: FolderFileEntry[] = [];
const folders: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
// A secret that happens to sit in an open folder stays out of the grid too.
if (isBlockedAttachmentPath(folderFilePath(dir, entry.name), guard.blockedTrees)) continue;
if (entry.kind === 'directory') {
folders.push(entry.name);
continue;
}
const extension = extname(entry.name).toLowerCase().replace(/^\./, '');
if (!isSupportedAttachmentExtension(extension)) continue;
files.push({
name: entry.name,
extension,
attachmentType: getAttachmentType(extension),
size: entry.size,
mtimeMs: entry.mtimeMs,
});
}
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
folders.sort((a, b) => a.localeCompare(b));
return { folderId: folderGrants.grant(sessionId, dir), path: dir, files, folders, truncated };
}
/** Local counterpart of the remote listing script: subfolders + supported files, stat'ed through symlinks. */
async function listLocalFolder(dir: string): Promise<{ entries: RemoteFolderEntry[]; truncated: boolean }> {
const dirents = await fs.readdir(dir, { withFileTypes: true });
const entries: RemoteFolderEntry[] = [];
let truncated = false;
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) continue;
if (entries.length >= MAX_FOLDER_ENTRIES) {
truncated = true;
break;
}
const extension = extname(dirent.name).toLowerCase().replace(/^\./, '');
const maybeFile = isSupportedAttachmentExtension(extension);
if (!dirent.isDirectory() && !maybeFile && !dirent.isSymbolicLink()) continue;
let stat;
try {
stat = await fs.stat(join(dir, dirent.name));
} catch {
continue; // dangling symlink, or gone since readdir
}
if (stat.isDirectory()) {
entries.push({ kind: 'directory', name: dirent.name, size: 0, mtimeMs: 0 });
} else if (stat.isFile() && maybeFile) {
entries.push({ kind: 'file', name: dirent.name, size: stat.size, mtimeMs: stat.mtimeMs });
}
}
return { entries, truncated };
}
+4
View File
@@ -495,6 +495,10 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
SSH_ASKPASS_REQUIRE: 'never',
DISPLAY: '',
GCM_INTERACTIVE: 'never',
// classifyGitFailure() matches git's ENGLISH stderr; a German or French locale would
// turn a missing ref into a generic FAILED (422 instead of 400).
LC_ALL: 'C',
LANG: 'C',
GIT_SSH_COMMAND:
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
};
+54 -20
View File
@@ -36,6 +36,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
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 { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
@@ -1228,8 +1229,8 @@ interface AgentSkillFile {
*/
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
const src = agentSkillSourceDir();
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8');
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
const skillMd = await readFile(join(src, 'SKILL.md'), 'utf-8');
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skillMd.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
let referenceNames: string[] = [];
try {
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`
* 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:
* - 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
* 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
* generation time). The path formula must match the skill's
* `${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
* shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (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.
* shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
* 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> {
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(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';
});
}
/**
* Add or remove the Codeman agent skill in `<case>/.claude/skills/codeman`,
* mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled`
* app setting (default OFF); callers gate on Claude mode, since the skill is discovered
* via `.claude/skills/`, which only Claude Code reads.
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
* had before `capabilities.agentSkillDir` existed.
*/
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
* skip the call), for the statusLine reason: sessions in a repo share one `.claude/`
* dir, so a single create while the setting is off must not yank the skill out from
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
* so a single create while the setting is off must not yank the skill out from
* under other live sessions.
*
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
* 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
* `codeman skill uninstall --case <name>`. The `enabled: false` branch 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.
* `codeman skill uninstall --case <name>`, which removes the Codeman-managed copy from
* every skills directory the registry could have written. The `enabled: false` branch
* 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> {
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
export async function applyAgentSkill(
casePath: string,
enabled: boolean,
dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
): Promise<AgentSkillApplyResult> {
const skillDir = agentSkillDirFor(casePath, dir);
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();
+6 -2
View File
@@ -14,6 +14,7 @@ import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
// ========== Types ==========
@@ -157,12 +158,15 @@ export class ImageWatcher extends EventEmitter {
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
// Ignore common heavy directories for performance
ignored: (path: string) => {
// Skip node_modules, .git, and other heavy directories
// Skip node_modules, .git, and other heavy directories, and Codeman's
// own upload folders: a pdf the user handed to the agent is not a file
// the agent produced.
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/')
path.includes('/.next/') ||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
) {
return true;
}
+1090
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
/**
* @fileoverview MCP sync targets that are not Codeman run modes.
*
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
* config file already exists (an absent tool is reported `absent`, never created), and sort after
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
*
* @module mcp-sync-targets
*/
import { accessSync, constants as fsConstants } from 'node:fs';
import { homedir } from 'node:os';
import { delimiter, join } from 'node:path';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncTarget } from './mcp-sync.js';
export interface McpSyncOnlyTool {
id: string;
label: string;
/** Home-relative default location of the MCP config file. */
path: string;
format: McpConfigFormat;
/** The env var the tool reads to move its home, and the file under it. */
relocation?: { envVar: string; path: string };
/** The executable whose presence on this machine means the tool is installed. */
binary: string;
}
export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [
{
id: 'copilot',
label: 'GitHub Copilot CLI',
path: '.copilot/mcp-config.json',
format: 'copilot-json',
// COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
binary: 'copilot',
},
];
/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */
export function binaryOnPath(name: string, env: Record<string, string | undefined> = process.env): boolean {
const dirs = [
...(env.PATH ?? '').split(delimiter).filter(Boolean),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
];
return dirs.some((dir) => {
try {
accessSync(join(dir, name), fsConstants.X_OK);
return true;
} catch {
return false;
}
});
}
/** The sync-only tools as sync targets, skipping any id the registry already provides. */
export function mcpSyncOnlyTargets(
taken: ReadonlySet<string>,
isInstalled: (binary: string) => boolean = binaryOnPath
): McpSyncTarget[] {
return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({
id: t.id,
label: t.label,
path: t.path,
format: t.format,
...(t.relocation ? { relocation: t.relocation } : {}),
installed: isInstalled(t.binary),
}));
}
+120 -6
View File
@@ -12,7 +12,8 @@
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* `enabled: false`, antigravity `disabled: true`, Copilot's `disabledMcpServers` in its
* `settings.json`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
@@ -39,7 +40,7 @@
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { basename, dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
@@ -65,6 +66,11 @@ export interface McpSyncTarget {
label: string;
/** Home-relative default location of the config file. */
path: string;
/**
* Home-relative fallback file used when `path` is absent: read, and written in place when it
* parses (a JSONC file with comments fails the parse and is never written).
*/
altPath?: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
@@ -196,11 +202,12 @@ function toClaude(s: McpServer): Record<string, unknown> {
function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
// today. A bare `url` with no type used to be the legacy SSE form, but gemini-cli v0.63.0 tries
// it as streamable HTTP first (and logs `httpUrl` as deprecated), so map it to 'http'.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
return clean({ transport: raw.type === 'sse' ? 'sse' : 'http', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
@@ -254,7 +261,7 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
const disabled = raw.enabled === false || raw.disabled === true;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
@@ -284,21 +291,58 @@ function toOpencode(s: McpServer): Record<string, unknown> {
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
/**
* GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
* `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
* this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
*/
function fromCopilot(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
}
if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toCopilot(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
}
return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
nestedKey?: string;
from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
/**
* A file beside the config that lists the names of servers the user switched off (the switch is
* not stored on the server entry). Read, never written.
*/
disabledIn?: { file: string; key: string };
}
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'copilot-json': {
key: 'mcpServers',
from: fromCopilot,
to: toCopilot,
disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
},
'opencode-json': {
key: 'mcp',
nestedKey: 'servers',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
@@ -370,7 +414,17 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
if (!dialect.nestedKey) return table;
const nested = table[dialect.nestedKey];
// OpenCode reads `mcp.servers` as a V1 server named "servers" when it carries a V1 `type`.
if (nested === undefined || (isRecord(nested) && (nested.type === 'local' || nested.type === 'remote'))) return table;
if (!isRecord(nested)) throw new McpConfigError(`"${dialect.key}.${dialect.nestedKey}" is not an object`);
// Merge the direct (V1) table with the nested (V2) one. Like OpenCode, the native V2 entry wins a
// name collision; sync never writes a name already defined, so this does not affect the read-back.
const merged = dict<unknown>();
for (const k of safeKeys(table)) if (k !== dialect.nestedKey) merged[k] = table[k];
for (const k of safeKeys(nested)) merged[k] = nested[k];
return merged;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
@@ -558,6 +612,56 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
/**
* The alternate config file for a target (`McpSyncTarget.altPath`), honouring the same relocation
* as the primary file: the alt lives in the same directory, so the relocated path is the primary's
* directory plus the alt filename. Once chosen it is read and written exactly like the primary; a
* JSONC file with comments fails the parse guard and is never written.
*/
function resolveAltFile(
t: McpSyncTarget,
altPath: string,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
if (!rel || dir === undefined || dir === '') return { file: join(home, altPath) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/…`,
skip: `${rel.envVar} is set to a relative path, so the alt file cannot be located safely`,
};
}
return { file: join(dir, dirname(rel.path), basename(altPath)) };
}
/**
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
* disabled, so they are not copied. If that file cannot be read as intended the target is
* reported unreadable rather than guessing: a guess could switch a server on everywhere.
*/
async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise<void> {
if (format === 'codex-toml') return;
const companion = JSON_DIALECTS[format].disabledIn;
if (!companion) return;
const text = await readText(join(dirname(file), companion.file));
if (text === null || !text.trim()) return;
let doc: unknown;
try {
doc = JSON.parse(text);
} catch {
throw new McpConfigError(
`${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
);
}
const list = isRecord(doc) ? doc[companion.key] : undefined;
if (list === undefined) return;
const names = strArr(list);
if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
}
let applying = false;
/**
@@ -606,11 +710,21 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
// A config kept under an alternate name (OpenCode's opencode.jsonc) is read when the primary
// file is absent, so its servers are synced rather than silently treated as "not installed".
if (s.t.altPath && !(await exists(s.file))) {
const alt = resolveAltFile(s.t, s.t.altPath, home, env);
if (!alt.skip && (await exists(alt.file))) {
s.file = alt.file;
s.res.file = alt.file;
}
}
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
+28 -1
View File
@@ -25,6 +25,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
StatusLineExporter,
} from './types.js';
/**
@@ -57,6 +58,8 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
statusLine?: StatusLineExporter;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
@@ -189,6 +192,21 @@ export interface PaneCaptureOptions {
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
/**
* Full-history only: scrollback rows read from somewhere other than this pane
* (raw `capture-pane -p -e -J` text, oldest first). The pane then contributes
* only its visible frame and caret, and its own history is not read. A remote
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
*/
scrollbackOverride?: string;
/**
* With `scrollbackOverride`: the screen rows of the pane that history came
* from, read right after it. The frame this pane captures later can have
* scrolled past some of them, or show only part of a taller screen, so the
* rows above where the frame's top overlaps them are kept between the
* scrollback and the frame (`joinScrollbackAndFrame`).
*/
scrollbackOverrideScreen?: string;
/**
* Byte cap the consumer will keep from the capture. Sizes the child-process
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
@@ -210,9 +228,18 @@ export interface PaneCaptureOptions {
* 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.
* Absent when the pane could not be queried.
* A full-history capture bounded by `historyLimitLines` below this depth
* left older lines behind in the multiplexer, which the caller must report
* as recoverable. Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
/**
* Filled in by the implementation with whether the pane was in the alternate
* screen at capture time, when it can tell. tmux keeps no history for such a
* pane (a nested tmux client puts it there), so a full-history capture of it
* holds one screen plus whatever the pane held before.
*/
capturedAlternateOn?: boolean;
}
/**
+126
View File
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
* `echo` (which land before the script runs) from the records without any "last N
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
*
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
* server does not know that home, and a clicked `~/…` link means the host the
* session runs on. The record's `realPath` is absolute, as for any other path.
*
* Symlink resolution is portable AND fails closed. `readlink -f` where available
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
'probe() {',
' i=$1',
' p=$2',
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
@@ -269,6 +275,126 @@ export async function remoteProbePaths(
return results;
}
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
export interface RemoteFolderEntry {
kind: 'file' | 'directory';
name: string;
size: number;
mtimeMs: number;
}
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
export interface RemoteFolderListing {
dir: string;
entries: RemoteFolderEntry[];
truncated: boolean;
}
/**
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
* one of `extensions` (any case), up to `maxEntries`.
*
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
*/
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
.join('|');
const script = [
`d=${shellescape(dir)}`,
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
"printf '\\0'",
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
'printf \'D|%s\\0\' "$r"',
'n=0',
'for f in "$r"/*; do',
' [ -e "$f" ] || continue',
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
' b=${f##*/}',
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
' [ -f "$f" ] || continue',
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
' n=$((n + 1))',
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
'done',
].join('\n');
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
// file — the same wrapper the transcript read uses.
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
*/
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
const records = stdout.split('\0').slice(1);
let dir: string | null = null;
let truncated = false;
const entries: RemoteFolderEntry[] = [];
for (const record of records) {
if (record === 'E|f') return 'not-a-folder';
if (record.startsWith('E|')) return null;
if (record.startsWith('D|')) {
dir = record.slice(2);
continue;
}
if (record === 'T|') {
truncated = true;
continue;
}
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
if (!match) continue;
const size = Number.parseInt(match[2], 10);
const mtime = Number.parseInt(match[3], 10);
entries.push({
kind: match[1] === 'd' ? 'directory' : 'file',
name: match[4],
size: Number.isFinite(size) && size > 0 ? size : 0,
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
});
}
if (!dir) {
throw new RemoteFileAccessError('remote host returned no usable folder listing');
}
return { dir, entries, truncated };
}
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
export async function remoteListFolder(
remote: SessionRemote,
dir: string,
extensions: readonly string[],
maxEntries: number
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
let stdout: string;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
);
stdout = result.stdout;
} catch (err) {
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
return parseRemoteFolderListOutput(stdout);
}
/** Read a whole remote file into memory, capped by `maxBytes`. */
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
assertNotUnderTest();
+301
View File
@@ -0,0 +1,301 @@
/**
* @fileoverview Scrollback of a remote SSH session, read from the REMOTE tmux.
*
* A remote session's local pane runs `ssh -t … tmux new-session -A` (or `attach`),
* so the local tmux only ever sees what the remote tmux CLIENT draws: full-screen
* repaints, not scrolling output. The local pane therefore keeps a fraction of the
* history (measured: 48 of 279 lines while output streamed, none after a reattach),
* while the remote pane, where the shell really runs, keeps all of it. A full-history
* load of a remote session reads the scrollback from there instead, and the caller
* splices it above the LOCAL visible frame (`PaneCaptureOptions.scrollbackOverride`):
* the local pane is what the live stream keeps drawing into, so its frame and caret
* position are the ones the browser must end up with.
*
* ONE ssh round trip reads the remote pane's scrollback rows (`capture-pane -S -<N>
* -E -1`), its `#{history_size}` and its screen. The size is load-bearing: with no
* history at all, tmux clamps `-E -1` to the first VISIBLE row and would return it
* as history. The screen closes the seam between the remote read and the local
* frame, which is read only after the round trip: rows that scrolled in between,
* and the top of a remote screen taller than the local pane, are on that screen
* alone, and the caller keeps the screen rows above where the local frame overlaps
* it (`joinScrollbackAndFrame`).
*
* ⚠️ The three reads are ONE tmux command list (`a \; b \; c`), not three tmux
* runs. tmux runs a client's command list back to back without reading pane output
* in between, so the history ends exactly where the screen begins. As separate runs
* the shell kept writing between them, and the rows that scrolled in those
* milliseconds were in neither capture (measured on a busy pane: every read lost
* rows). This rests on how tmux's command queue behaves, not on a documented
* guarantee. tmux output cannot carry the NUL markers, so a random marker line
* printed by `display-message` separates the history from the size and screen.
* tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be
* glued onto an unterminated last history row; the parser accepts it there too.
*
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
* output) returns null and the caller keeps the local capture, so a remote load is
* never worse than before. A transport failure also starts a short per-host back-off,
* so a host that is asleep costs one timeout, not one per tab switch. The child runs
* under the shared remote-ssh limiter (`remote-ssh-limiter.ts`) like every other
* short-lived remote ssh, but gives up on a slot it waited too long for (`maxWaitMs`,
* enforced in the queue), and the connection options come from
* `buildSshConnectionArgs()`.
*
* How much history exists is the REMOTE tmux's `history-limit` (2000 lines unless
* the remote user's tmux.conf raises it); the launch command does not set it.
*/
import { exec } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { promisify } from 'node:util';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { remoteTmuxLocation } from './tmux-manager.js';
const execAsync = promisify(exec);
/**
* Bound on the whole round trip. A full load waits on it, so it is far below the
* 10 s `ConnectTimeout` the shared ssh options carry: past this the local capture
* is the better answer.
*/
const REMOTE_HISTORY_TIMEOUT_MS = 5_000;
/**
* Longest a load waits for a slot in the shared ssh pool, which file previews and
* attachment probes (20 s each) also hold. Past it the local capture is used.
*/
const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500;
/** After a failed read, skip that host for this long and use the local capture. */
const REMOTE_HISTORY_BACKOFF_MS = 30_000;
/**
* Headroom over the remote `tail -c` bound for what it does not cover: the two NUL
* markers and anything a login banner or rc file prints before the script.
*/
const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024;
/**
* Added to the configured byte cap for the remote `tail -c` bound. That bound
* covers the whole tmux output, and the marker, the size and the screen come last
* (a wide, tall screen of colored rows runs to a few hundred KB); without this, a
* small cap would cut into the screen and lose the read. The route trims what it
* returns to the configured cap anyway, keeping the newest bytes.
*/
const REMOTE_SCREEN_ALLOWANCE_BYTES = 1024 * 1024;
/** The remote `tail -c` bound for a configured byte cap; 0 (unbounded) stays 0. */
export function remoteTailBytes(maxBytes: number): number {
return maxBytes > 0 ? Math.trunc(maxBytes) + REMOTE_SCREEN_ALLOWANCE_BYTES : 0;
}
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
/**
* Host id → time the back-off ends. Keyed by HOST, so a sleeping host with five
* sessions costs one timeout, and the map is bounded by the configured hosts.
*/
const backoffUntil = new Map<string, number>();
/**
* A fresh marker line for one read. Random, so no row the pane really holds can be
* taken for it.
*/
export function newScrollbackMarker(): string {
return `codeman-history-${randomBytes(8).toString('hex')}`;
}
/**
* The remote shell script. It prints a NUL, the tmux output, then a second NUL:
* the first separates a login banner or an rc-file `echo` printed before the
* script runs without guessing (the same marker `buildRemoteProbeCommand` uses),
* and both are printed whether or not tmux succeeds, so their presence says the
* script ran on the host. The tmux output is the history rows, `marker`, the
* history size, then the screen rows.
*/
export function buildRemoteScrollbackScript(
socket: string,
sessionName: string,
historyLines: number,
tailBytes: number,
marker: string
): string {
// `=name:` is an EXACT session match, then its active pane. A bare name is
// read as a pane index or window name first, so a discovered session named
// `0` would capture some other session's pane.
const target = shellescape(`=${sessionName}:`);
const lines = Math.max(1, Math.trunc(historyLines));
// The newest rows are the ones worth keeping, so the byte bound is applied on
// the remote side (`tail -c`) rather than by killing the read at maxBuffer.
// It covers the whole tmux output, history first, so with the screen
// allowance in `remoteTailBytes()` a cut lands in the history. 0 means
// unbounded, as in the route.
const bound = tailBytes > 0 ? ` | tail -c ${Math.trunc(tailBytes)}` : '';
// ONE tmux command list, so no pane output is read between the history and
// the screen (see the @fileoverview). `\;` reaches tmux as a lone `;`.
return (
`printf '\\0'; tmux -L ${shellescape(socket)} ` +
`capture-pane -p -e -J -S -${lines} -E -1 -t ${target} \\; ` +
`display-message -p -t ${target} ${shellescape(marker)} \\; ` +
`display-message -p -t ${target} '#{history_size}' \\; ` +
`capture-pane -p -e -J -t ${target}${bound}; printf '\\0'`
);
}
/** The full ssh command line for {@link buildRemoteScrollbackScript}. */
export function buildRemoteScrollbackCommand(
remote: SessionRemote,
sessionId: string,
historyLines: number,
tailBytes: number,
marker: string
): string {
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
}
/** What one round trip read from the remote pane: its scrollback rows, then its screen. */
export interface RemoteScrollback {
/** History rows above the screen, oldest first; '' when the pane has none. */
scrollback: string;
/** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */
screen: string;
}
/**
* The scrollback and screen rows from the script's stdout, or null when the
* output is not the script's or tmux printed nothing usable (no such session,
* or a byte cap so small it cut into the screen). The marker may end an
* unterminated history row (tmux before 3.2); when the output reached
* `tailBytes`, the history's partial first row is dropped.
*/
export function parseRemoteScrollbackOutput(
stdout: string,
marker: string,
tailBytes: number
): RemoteScrollback | null {
const start = stdout.indexOf('\0');
if (start === -1) return null;
const end = stdout.indexOf('\0', start + 1);
if (end === -1) return null;
// The marker ends a row, after the history's last row. Usually it is a row of
// its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so
// when the history ends inside a wrapped line (`-J` leaves it unterminated)
// the marker is glued onto that row. It is random per read, so no row the
// pane really holds ends with it.
const markerRow = `${marker}\n`;
const raw = stdout.slice(start + 1, end);
const at = raw.indexOf(markerRow);
if (at === -1) return null;
const sizeStart = at + markerRow.length;
const sizeEnd = raw.indexOf('\n', sizeStart);
if (sizeEnd === -1) return null;
const sizeText = raw.slice(sizeStart, sizeEnd).trim();
if (!/^\d+$/.test(sizeText)) return null;
const screen = raw.slice(sizeEnd + 1);
// With no history at all, tmux clamps `-E -1` to the first visible row.
if (Number(sizeText) === 0) return { scrollback: '', screen };
// A tail -c cut lands in the history (the bound leaves room for the rest),
// mid-row and possibly mid-escape, so the first, partial row is dropped. A
// cut exactly on the marker row leaves no history at all.
let scrollback = raw.slice(0, at);
if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) {
const newline = scrollback.indexOf('\n');
scrollback = newline === -1 ? '' : scrollback.slice(newline + 1);
}
if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n';
return { scrollback, screen };
}
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
export type RemoteScrollbackRunner = (command: string, opts: { timeout: number; maxBuffer: number }) => Promise<string>;
const execRunner: RemoteScrollbackRunner = async (command, opts) =>
(await execAsync(command, { encoding: 'utf-8', ...opts })).stdout;
/**
* Read a remote session's scrollback from the remote tmux, or null to keep the
* local capture. Never throws.
*
* Only a TRANSPORT failure backs the host off (ssh's own exit 255, a timeout, a
* spawn error, output that is not the script's): those say the host is
* unreachable for every session on it. A remote command failing (a session
* that is gone: tmux answers nothing between the NULs, and the closing `printf`
* hides its exit status) is about this session only, so it returns
* null and the next session on that host still tries. Same split as
* `classifyRemoteAliveExit()`.
*
* `run` is a test seam: without it nothing runs under vitest, as in
* remote-files.ts / remote-hosts.ts.
*/
export async function fetchRemoteScrollback(
remote: SessionRemote,
sessionId: string,
historyLines: number,
maxBytes: number,
run?: RemoteScrollbackRunner
): Promise<RemoteScrollback | null> {
if (!run && process.env.VITEST) return null;
const runner = run ?? execRunner;
const until = backoffUntil.get(remote.hostId);
if (until !== undefined) {
if (Date.now() < until) return null;
backoffUntil.delete(remote.hostId);
}
const marker = newScrollbackMarker();
const tailBytes = remoteTailBytes(maxBytes);
const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, tailBytes, marker);
try {
const stdout = await runWithRemoteSshLimit(
() =>
runner(command, {
timeout: REMOTE_HISTORY_TIMEOUT_MS,
maxBuffer: (tailBytes > 0 ? tailBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES,
}),
// The pool is shared with 20 s file probes; past this the local capture
// is the better answer than waiting behind them.
{ maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS }
);
const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes);
if (parsed === null) {
// Both NULs arrived, so the script ran on the host and only tmux had
// nothing to say: no such session. The script's last command is the
// closing `printf`, so this comes back as exit 0 rather than tmux's
// exit 1, and it is about this session alone. Output without the NULs
// is not the script's at all, which says the host is not answering as
// expected.
const parts = stdout.split('\0');
const ranOnHost = parts.length >= 3;
if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
// tmux printed something, but not the marker, size and screen in order.
const unreadable = ranOnHost && parts[1] !== '';
const what = !ranOnHost
? 'unexpected output'
: unreadable
? 'unreadable remote tmux output'
: 'no such remote tmux session';
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
return null;
}
return parsed;
} catch (err) {
// A busy pool says nothing about the host.
if (err instanceof RemoteSshSlotTimeoutError) return null;
const e = err as { killed?: boolean; code?: unknown };
const code = typeof e.code === 'number' ? e.code : null;
const transport = classifyRemoteAliveExit(code, e.killed === true) === undefined;
if (transport) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
const reason = e.killed ? 'timed out' : code !== null ? `exit ${code}` : 'failed';
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
return null;
}
}
/** Test hook: forget every host back-off. */
export function resetRemoteScrollbackBackoff(): void {
backoffUntil.clear();
}
+36 -6
View File
@@ -1,7 +1,9 @@
/**
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
* the buffered text read).
* the buffered text read). A remote session's full-history load reads its scrollback
* from the remote tmux through the same pool (`src/remote-pane-history.ts`), with a
* bounded wait (`maxWaitMs`) because it has the local capture to fall back on.
*
* Two paths can fan those out without a human behind each one:
*
@@ -60,12 +62,35 @@ export function getRemoteSshLimit(): number {
return MAX_CONCURRENT_REMOTE_SSH;
}
function acquire(): Promise<void> {
/** Thrown by {@link runWithRemoteSshLimit} when `maxWaitMs` passed before a slot freed up. */
export class RemoteSshSlotTimeoutError extends Error {
constructor(maxWaitMs: number) {
super(`No remote ssh slot within ${maxWaitMs} ms`);
this.name = 'RemoteSshSlotTimeoutError';
}
}
function acquire(maxWaitMs?: number): Promise<void> {
if (active < MAX_CONCURRENT_REMOTE_SSH) {
active++;
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
if (maxWaitMs === undefined) return new Promise<void>((resolve) => waiters.push(resolve));
return new Promise<void>((resolve, reject) => {
const waiter = () => {
clearTimeout(timer);
resolve();
};
// A timed-out waiter leaves the queue and is never handed a slot, so it
// owns nothing to give back: `active` is untouched on this path.
const timer = setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index === -1) return; // already handed a slot by release()
waiters.splice(index, 1);
reject(new RemoteSshSlotTimeoutError(maxWaitMs));
}, maxWaitMs);
waiters.push(waiter);
});
}
function release(): void {
@@ -78,9 +103,14 @@ function release(): void {
}
}
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
await acquire();
/**
* Run `task` once an ssh slot is free, releasing the slot afterward. With
* `maxWaitMs`, give up instead (rejecting with {@link RemoteSshSlotTimeoutError},
* `task` never runs) when no slot frees up in time: for a caller that has a
* cheaper answer than waiting behind the queue.
*/
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>, opts: { maxWaitMs?: number } = {}): Promise<T> {
await acquire(opts.maxWaitMs);
try {
return await task();
} finally {
+57
View File
@@ -63,6 +63,7 @@ import {
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
type PromptCacheStatus,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -585,6 +586,8 @@ export class Session extends EventEmitter {
* running. A session that comes back with no label has no chip on its screen.
*/
private _watching: string | null = null;
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
private _promptCache: PromptCacheStatus | null = null;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
@@ -873,6 +876,8 @@ export class Session extends EventEmitter {
paneExit?: PaneExit;
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
displayModel?: DisplayModel;
/** The previous run's last prompt-cache report, restored so the readout is not blank (see `promptCache`). */
promptCache?: PromptCacheStatus;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
@@ -1041,6 +1046,11 @@ export class Session extends EventEmitter {
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
// Last prompt-cache report, restored so the Session Options readout is not blank until
// the pane's next statusline render. The readout is time-aware, so a value whose expiry
// has passed renders as cold rather than stale-warm. Set directly, not via
// setPromptCache, so recovery does not re-stamp a compaction it did not just observe.
this._promptCache = config.promptCache ?? null;
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1244,6 +1254,11 @@ export class Session extends EventEmitter {
}
if (newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
// The conversation itself changed (a post-/clear switch), so the old prefix's cache
// report and its compaction baseline belong to a conversation that is gone. Drop them
// rather than show the previous conversation's "warm until" against the new one; the
// next statusline render repopulates. A same-id reattach returned above, untouched.
this._promptCache = null;
}
/**
@@ -1414,6 +1429,46 @@ export class Session extends EventEmitter {
return this._watching;
}
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
* report. It rides `toState()` for the Session Options readout and is restored on a
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
* as cold rather than as a stale warm.
*/
get promptCache(): PromptCacheStatus | null {
return this._promptCache;
}
/**
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
* `compactedAt` when this report ENTERS the window in which Claude Code reads
* `recacheTokensIfCold` as null: a compaction (`/compact` or its own idle compaction) or
* a clearing of old tool results opened it, and the previous report still had a known
* value. Claude Code closes the window on the next request (that request is the one that
* counts as an expected rebuild), so `expectedRebuilds` never rises inside it and is not
* read here. The stamp is carried while the window holds and dropped once it closes; a
* first report already inside the window gets none. It is never an alert.
*/
setPromptCache(status: PromptCacheStatus): boolean {
const prev = this._promptCache;
const inWindow = status.warm && status.recacheTokensIfCold == null;
let compactedAt: number | undefined;
if (inWindow && prev?.recacheTokensIfCold != null) {
compactedAt = Date.now();
} else if (inWindow && prev?.compactedAt != null) {
compactedAt = prev.compactedAt;
}
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
// route broadcasts the STORED object, so the stamp lives here and never leaks back into the
// parser's output (which would make the route's "broadcast the stored object" choice vacuous).
const next = compactedAt != null ? { ...status, compactedAt } : status;
const changed = JSON.stringify(next) !== JSON.stringify(prev);
this._promptCache = next;
return changed;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -1893,6 +1948,8 @@ export class Session extends EventEmitter {
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineExporter: this._muxSession?.statusLine,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
+68 -12
View File
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
const isRanked = (ref: TabRef): boolean =>
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
const prepare = (ref: TabRef): TabRef => {
if (ref.kind !== 'session') return { ...ref };
const item = sessionMetadata.get(ref.id);
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
};
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
const ranked = refs
.filter(isRanked)
.map(prepare)
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
let rankedIndex = 0;
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
};
const transformed: TabLayout = {
const ranked: TabLayout = {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
ungrouped: rankContainer(layout.ungrouped),
};
const manual = childrenSplitFromParent(ranked, sessionMetadata);
const pin = (ref: TabRef): TabRef =>
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
const transformed: TabLayout = {
...ranked,
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
ungrouped: ranked.ungrouped.map(pin),
};
return normalizeTabLayout(transformed, metadata);
}
/**
* The following children (no `manual` placement, an owner-valid parent) that a
* ranked layout separates from their parent's block. A parent's block is the
* parent and, right after it in the same container, its following descendants;
* sibling order inside the block is free, and a web tab the ranking left in a
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
* a PUT of the current order, or one that moves a family together, leaves every
* stored placement alone. Pinning one child shrinks its parent's block, so the
* check repeats until nothing more is pinned.
*/
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
const following = new Map<string, string>();
for (const refs of containers) {
for (const ref of refs) {
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
}
}
const descendsFrom = (id: string, ancestorId: string): boolean => {
let current = following.get(id);
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
if (current === ancestorId) return true;
current = following.get(current);
}
return false;
};
const manual = new Set<string>();
for (let changed = true; changed; ) {
changed = false;
for (const refs of containers) {
const index = new Map<string, number>();
refs.forEach((ref, at) => {
if (ref.kind === 'session') index.set(ref.id, at);
});
refs.forEach((ref, at) => {
if (ref.kind !== 'session') return;
const parentId = following.get(ref.id);
if (parentId === undefined) return;
const parentAt = index.get(parentId);
const inBlock =
parentAt !== undefined &&
parentAt < at &&
refs
.slice(parentAt + 1, at)
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
if (inBlock) return;
following.delete(ref.id);
manual.add(ref.id);
changed = true;
});
}
}
return manual;
}
export function recomposeGlobalSessionOrder(
current: readonly string[],
projections: readonly OwnerOrderProjection[],
+127 -6
View File
@@ -11,7 +11,9 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
import {
flattenOwnerSessionOrder,
materializeOrphans,
moveRef,
normalizeTabLayout,
setManualPlacement,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
@@ -42,6 +44,12 @@ interface TabLayoutServiceDeps {
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
broadcast(event: string, data: unknown): void;
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
/**
* The synced `spawnedTabsFollowParent` setting, read at every creation. Off (or
* absent), a spawned session lands after its parent once and is hand-placed
* from then on; on, it keeps following its parent until moved by hand.
*/
childrenFollowParent?(): boolean | Promise<boolean>;
now?: () => string;
}
@@ -67,6 +75,18 @@ export interface RemovedTabLayoutSession {
owner?: string;
}
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
export interface SessionPlacementHint {
tabGroupId?: string;
}
/**
* Adds the created session to the live map. Runs INSIDE the owner lock, so no
* other layout mutation can observe (and commit) the session first; the returned
* function undoes it when the layout write fails.
*/
export type RegisterCreatedSession = () => void | (() => void);
interface PreparedOwnerLayout {
current: TabLayout | null;
authoritative: TabLayout;
@@ -97,6 +117,66 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) ==
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && a.every((id, index) => id === b[index]);
/**
* Hand-place a just-created child where normalization put it (right after its
* parent), so it stops following. A session that does not follow anything is
* left alone.
*/
function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): TabLayout {
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
const parentId = item?.parentSessionId;
if (!item?.ownerValid || !parentId) return layout;
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
const ref = refs.find((candidate) => candidate.kind === 'session' && candidate.id === sessionId);
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
const pinned = setManualPlacement(layout, ref, true);
// Normalization put the child right after its parent's FOLLOWING block, i.e.
// ahead of the siblings pinned before it. Step past those (and their
// descendants) so a fan-out keeps creation order.
const parentOf = new Map<string, string>();
for (const fact of metadata) {
if (fact.kind === 'session' && fact.ownerValid && fact.parentSessionId) parentOf.set(fact.id, fact.parentSessionId);
}
const descendsFromParent = (id: string): boolean => {
let current = parentOf.get(id);
for (let hops = 0; current !== undefined && hops <= parentOf.size; hops++) {
if (current === parentId) return true;
current = parentOf.get(current);
}
return false;
};
const isChild = (candidate: TabRef): boolean => candidate.kind === 'session' && candidate.id === sessionId;
const group = pinned.groups.find((candidate) => candidate.refs.some(isChild));
const container = group ? group.refs : pinned.ungrouped;
const from = container.findIndex(isChild);
let to = from;
while (to + 1 < container.length && container[to + 1].kind === 'session' && descendsFromParent(container[to + 1].id))
to++;
if (to === from) return pinned;
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
}
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
function placeCreatedSession(
layout: TabLayout,
sessionId: string,
groupId: string,
metadata: readonly TabRefMetadata[]
): TabLayout {
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) return layout;
const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length;
try {
return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata);
} catch (error) {
// Not an owner-valid ref of this layout: the hint does not apply.
if (error instanceof TabLayoutValidationError) return layout;
throw error;
}
}
export class TabLayoutService {
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
private readonly ownerQueues = new Map<string, Promise<void>>();
@@ -320,9 +400,13 @@ export class TabLayoutService {
};
}
private async getUnlocked(owner: string): Promise<TabLayout> {
private async getUnlocked(
owner: string,
adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout
): Promise<TabLayout> {
const prepared = await this.prepareUnlocked(owner);
if (!prepared.needsReconciliationCommit) {
const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative;
if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) {
const publication = {
owner,
previous: prepared.current,
@@ -335,7 +419,7 @@ export class TabLayoutService {
return prepared.authoritative;
}
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
return this.commit(owner, base, next, prepared.metadata, prepared.current);
}
async get(owner: string): Promise<TabLayout> {
@@ -442,9 +526,46 @@ export class TabLayoutService {
}
}
/** Reconcile one completed session creation into one versioned mutation. */
async sessionCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
/**
* Reconcile one completed session creation into one versioned mutation.
*
* `placement.tabGroupId` puts the new session at the end of that group in the
* same write. The group is looked up in THIS owner's layout only, so a group id
* from another owner (or one deleted meanwhile) is simply not found and the
* session keeps its normal placement: after its parent when it has one,
* otherwise at the end of Ungrouped. An explicit group is a hand placement, so
* a child session placed this way is marked `manual` and stops following.
*
* Whether a child placed by its lineage KEEPS following is the synced
* `spawnedTabsFollowParent` setting, read here at creation: off (the default),
* the child lands after its parent once and is marked `manual` in the same
* write; on, it follows its parent until moved by hand. Flipping the setting
* later leaves existing tabs as they are.
*/
async sessionCreated(
owner: string,
sessionId?: string,
placement: SessionPlacementHint = {},
register?: RegisterCreatedSession
): Promise<TabLayout> {
return this.withOwner(owner, async () => {
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
const rollback = register?.();
try {
const groupId = placement.tabGroupId;
const adjust =
sessionId && (groupId || !follow)
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
}
: undefined;
return await this.getUnlocked(owner, adjust);
} catch (error) {
if (typeof rollback === 'function') rollback();
throw error;
}
});
}
/** Reconcile one completed saved-webview creation into one versioned mutation. */
+21 -2
View File
@@ -15,6 +15,13 @@ export interface TabRef {
kind: TabRefKind;
id: string;
placement?: 'manual';
/**
* Pinned to the vertical rail's Focus section. The browser draws a SHORTCUT to
* the ref there; the ref itself stays in its group, so the flag never moves
* anything and nothing that moves a ref may drop it. Only `true` is stored:
* an unfocused ref carries no key at all.
*/
focus?: true;
}
export interface TabGroup {
@@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef {
if (value.placement !== undefined && value.placement !== 'manual') {
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
}
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
if (value.focus !== undefined && value.focus !== true) {
throw new TabLayoutValidationError(`${label}.focus must be true when present`);
}
const ref: TabRef = { kind: value.kind, id };
if (value.placement === 'manual') ref.placement = 'manual';
if (value.focus === true) ref.focus = true;
return ref;
}
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
@@ -395,6 +408,11 @@ export function setManualPlacement(input: TabLayout, target: TabRef, manual: boo
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
}
/** Pin `target` to the Focus section, or unpin it. Moves nothing; idempotent both ways. */
export function setTabFocus(input: TabLayout, target: TabRef, focused: boolean): TabLayout {
return mapRef(input, target, ({ focus: _focus, ...ref }) => (focused ? { ...ref, focus: true } : ref));
}
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
const normalized = normalizeTabLayout(input, metadata);
if (target.kind !== 'session') {
@@ -420,7 +438,8 @@ export function followParent(input: TabLayout, target: TabRef, metadata: readonl
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
}
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
// Clear the manual placement only: a focused ref stays focused.
const cleared = mapRef(normalized, target, ({ placement: _placement, ...ref }) => ref);
return normalizeTabLayout(cleared, metadata);
}
+177 -20
View File
@@ -59,6 +59,7 @@ import {
type SessionDocker,
type DockerCommandMode,
type PaneExit,
type StatusLineExporter,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -694,6 +695,102 @@ export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */
function captureRows(text: string): string[] {
return text === '' ? [] : text.replace(/\n$/, '').split('\n');
}
/**
* Least overlap that places a frame in a screen: rows compared, and how many
* of them hold text. A placement too LOW repeats the rows between the true one
* and it, so a short overlap (one `$` or `❯` row, a rule line) is not trusted:
* past this, the plain join, which can only lose rows, is the safer answer.
*/
const FRAME_MATCH_MIN_ROWS = 3;
const FRAME_MATCH_MIN_CONTENT_ROWS = 2;
/**
* A captured row as plain text: escape sequences (SGR, OSC 8 links) and
* trailing blanks dropped. Two tmuxes render the same cell content, but need
* not encode its attributes the same way.
*/
function capturedRowText(row: string): string {
// eslint-disable-next-line no-control-regex
return row.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g, '').trimEnd();
}
/**
* Where a later frame of the same pane begins among the rows of an earlier
* screen capture, or -1 when that cannot be told.
*
* The frame's top rows must equal the screen's rows from that index on, as
* text. Two rows are left out of the comparison: the screen's trailing blank
* rows (output after the screen read fills them in the frame) and its last
* content row (output in flight may have finished it since). Every compared
* row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with
* FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins,
* so a block that merely repeats further down is only chosen when nothing
* above it matches. A row that changed between the two reads (a spinner, a
* clock) fails the true placement; the minimum keeps a lower one from being
* taken on a row or two that happen to be equal.
*
* An agent TUI mid-turn (Claude Code, codex, opencode) is not placed: it keeps
* a live region (spinner, composer, footer) pinned to the bottom and inserts new
* rows above it, so the frame is not a shifted copy of the screen and the
* comparison down to the screen's last content row fails. The caller then falls
* back to the plain join. A plain shell places, unless the frame starts on the
* second half of a wrapped line: -J holds that line as one screen row, so it
* matches nothing and the plain join applies too.
*/
export function findFrameInScreen(screenRows: string[], frameRows: string[]): number {
const screen = screenRows.map(capturedRowText);
const frame = frameRows.map(capturedRowText);
let lastContent = screen.length - 1;
while (lastContent >= 0 && screen[lastContent] === '') lastContent--;
for (let start = 0; start < lastContent; start++) {
const overlap = Math.min(lastContent - start, frame.length);
if (overlap < FRAME_MATCH_MIN_ROWS) break; // only shrinks from here on
let matches = true;
let contentRows = 0;
for (let k = 0; k < overlap && matches; k++) {
if (screen[start + k] !== frame[k]) matches = false;
else if (frame[k] !== '') contentRows++;
}
if (matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS) return start;
}
return -1;
}
/**
* Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`)
* above a visible-frame capture, as one capture in tmux's own shape: rows joined
* by a bare `\n`. Undefined or empty scrollback leaves the frame as it is.
*
* `screen` is that other pane's screen, read right after its scrollback. The
* frame is captured later, so rows can have scrolled out of it in between, and
* a frame showing part of a taller screen starts below that screen's top.
* Either way the screen rows above the frame's first row are in neither the
* scrollback nor the frame, so they go between the two. When the frame cannot
* be placed in the screen ({@link findFrameInScreen}), the join is the plain
* scrollback-over-frame one.
*/
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string, screen?: string): string {
let above = scrollback ? captureRows(scrollback) : [];
if (screen) {
const screenRows = captureRows(screen);
const frameStart = findFrameInScreen(screenRows, captureRows(frame));
if (frameStart > 0) {
// The remote screen is a capture of its own too, encoded from the default
// state: the history's last row must not leave its attributes active.
if (above.length > 0) above[above.length - 1] += '\x1b[0m';
above = above.concat(screenRows.slice(0, frameStart));
}
}
// Separate captures: tmux encodes the frame relative to the DEFAULT state, so
// the rows above it must not leave their attributes active.
return above.length > 0 ? `${above.join('\n')}\x1b[0m\n${frame}` : frame;
}
/** Pane geometry and caret position, as `display-message` reports them. */
interface PaneCursorGeometry {
cols: number;
@@ -706,13 +803,18 @@ interface PaneCursorGeometry {
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines?: number;
/** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */
alternateOn?: boolean;
}
/**
* Read the pane's cursor and size, or null when tmux cannot say.
*
* Every field is validated together: a caller that gets a value back can place
* a caret with it, and one that gets null must not try.
* The four geometry fields are validated together: a caller that gets a value
* back can place a caret with it, and one that gets null must not try. The
* optional fifth and sixth fields, the history depth and the alternate-screen
* flag, are only informational, so a bad one is dropped rather than
* discarding a good caret.
*/
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
let raw: string;
@@ -722,7 +824,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows, historyLines, alternateOn] = raw
.split(/\s+/)
.map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -737,6 +841,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
}
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;
}
@@ -970,6 +1075,19 @@ export function remoteTmuxSessionName(sessionId: string): string {
return `codeman-ssh-${sessionId.slice(0, 8)}`;
}
/**
* The remote tmux socket and session a remote Codeman session's pane is attached
* to, matching the command that attached it: an owned session is the one
* `buildRemoteLaunchCommand` created on the private socket, a non-owned one is the
* discovered session `buildRemoteAttachCommand` joined on `-L codeman`.
*/
export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): { socket: string; sessionName: string } {
if (remote.owned === false) {
return { socket: 'codeman', sessionName: remote.remoteSessionName || remoteTmuxSessionName(sessionId) };
}
return { socket: REMOTE_TMUX_SOCKET, sessionName: remoteTmuxSessionName(sessionId) };
}
/**
* COD-104 — build the SSH command that launches (or reattaches) a remote
* session INSIDE a tmux server on the remote host, so the remote agent survives
@@ -2047,6 +2165,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* The statusLine exporter for one claude launch, and why it is missing when it is.
* Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns
* only (remote/docker have their own separate command builders). Resolving still runs
* with collection off, because it also self-heals: it strips any legacy disk-written
* exporter from an older Codeman build the first time a session starts in that
* workspace again. `state` lands on the mux record so the Status row can
* say why no cache report will come, instead of "not reported yet" forever.
*/
private async _resolveStatusLine(
mode: SessionMode,
workingDir: string,
remoteOrDocker: boolean
): Promise<{ command?: string; state?: StatusLineExporter }> {
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
if (remoteOrDocker) return { state: 'remote' };
const enabled = await readPlanUsageTelemetryEnabled();
const command = await resolveStatusLineCliCommand(workingDir, enabled);
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
}
/**
* Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
@@ -2164,14 +2303,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// Registry-gated (capabilities.statusLineTelemetry — claude only today), local
// spawns only (remote/docker have their own separate command builders — out of
// scope here). Also self-heals: strips any legacy disk-written exporter from an
// older Codeman build the first time a session starts in that workspace again.
const statusLineCommand =
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
);
// The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting.
@@ -2335,6 +2471,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode,
attached: false,
name,
statusLine,
};
this.sessions.set(sessionId, session);
@@ -2459,11 +2596,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// See createSession()'s identical resolution for rationale.
const statusLineCommand =
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
);
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
const baseCmd = buildSpawnCommand({
@@ -2526,6 +2663,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
if (session.statusLine !== statusLine) {
session.statusLine = statusLine;
this.saveSessions();
}
return pid;
} catch (err) {
console.error('[TmuxManager] Failed to respawn pane:', err);
@@ -3878,7 +4019,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
? Math.trunc(requestedLines)
: DEFAULT_TMUX_HISTORY_LIMIT;
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
// With a scrollback override this pane's own history is not read at all:
// `-S 0` starts at the top of the visible frame.
const scrollbackOverride = fullHistory ? opts?.scrollbackOverride : undefined;
const captureFlags = !fullHistory
? 'capture-pane -p -e'
: scrollbackOverride !== undefined
? 'capture-pane -p -e -J -S 0'
: `capture-pane -p -e -J -S -${historyLines}`;
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
// (ENOBUFS) and would silently degrade full-history capture to the byte
// buffer for exactly the long sessions it exists for — size it from the
@@ -3891,14 +4039,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
const rawCapture = joinScrollbackAndFrame(
scrollbackOverride,
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
);
// Query the cursor BEFORE deciding anything else. On the full-history path
// it settles both how the capture is trimmed and whether a cursor move is
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
`${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 }
)
);
@@ -3912,8 +4064,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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.
// none, and the partial-history notice must not promise it. A caller that
// bounded the capture with `historyLimitLines` also reads it to tell
// whether the bound cut anything.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
// A pane in the alternate screen keeps no history (a nested tmux client
// holds it there), so its capture is one screen however deep it reads.
if (opts && geometry?.alternateOn !== undefined) opts.capturedAlternateOn = geometry.alternateOn;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+9 -47
View File
@@ -48,6 +48,12 @@ import https from 'node:https';
import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
import {
basicAuthHeader,
parseEnvFile,
readCodemanCredentials,
type CodemanCredentials,
} from '../codeman-credentials.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js';
@@ -281,53 +287,9 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
}
/**
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
* lines and `#` comments skipped, one layer of matching quotes stripped.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
export interface TuiCredentials {
username: string;
password?: string;
}
/**
* Credentials for the API, env first and the data dir's `.env` as the fallback,
* exactly like the `codeman attach` path. No password means no auth is
* configured (or the user has it only in the server's environment, in which
* case the API answers 401 and `connect()` reports `authRequired`).
*/
export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
let fileEnv: Record<string, string> = {};
try {
fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
/* absent or unreadable: env-only */
}
const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// One credential reader for every client of the API (attach, tui, agent).
export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
export type TuiCredentials = CodemanCredentials;
// ─────────────────────────────────────────────────────────────────────────────
// Degraded mode
+55
View File
@@ -724,6 +724,51 @@ export interface PaneExit {
at: number;
}
/**
* Normalized prompt-cache state of a session's main conversation, from the statusline's
* `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only
* field Claude always reports: once the prefix has gone cold it sends `warm:false` with a
* null `expires_at`.
*/
export interface PromptCacheStatus {
warm: boolean;
/** Cache lifetime of the current prefix; Claude Code only ever reports these two. */
ttl?: '5m' | '1h';
/** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */
expiresAt?: number;
/** Tokens the next request re-writes if the cache has gone cold by then. */
recacheTokensIfCold?: number;
/** Requests this session that re-processed content the cache already held. */
misses?: number;
/** Cache rebuilds that followed a compaction or a clearing of old tool results. */
expectedRebuilds?: number;
/** Cache read tokens as a fraction of all input tokens this session, 0-1. */
hitRatio?: number;
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
lastMissCauses?: string[];
/**
* Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle
* compaction, or a clearing of old tool results): the first warm report whose
* `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds,
* dropped at the next request. Derived in `Session.setPromptCache`, not reported by the
* statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted
* at HH:MM"; it is never an alert.
*/
compactedAt?: number;
}
/**
* Whether this launch carries Codeman's statusLine exporter, the only source of
* `promptCache`, and why not when it does not. Decided once per claude spawn
* (`TmuxManager`), so a setting flipped later does not change it until the next launch.
* - `injected`: the exporter is on the launch.
* - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch.
* - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a
* statusLine (or could not be read), and Codeman never replaces one there.
* - `remote`: remote and Docker launches never carry the exporter.
*/
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -775,6 +820,16 @@ export interface SessionState {
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first
* report. Restored on a restart (the Session constructor's `promptCache`) so the readout
* is not blank until the pane's next render; the readout is time-aware, so a value whose
* `expiresAt` has passed reads as cold rather than as a stale warm.
*/
promptCache?: PromptCacheStatus;
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
statusLineExporter?: StatusLineExporter;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
+92 -8
View File
@@ -15,6 +15,13 @@
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
* before the first API response and for non-subscriber auth — both yield null.
*
* Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the
* main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`,
* `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset
* Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and
* `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes,
* so the footer flips to cold on its own.
*
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
* response and selects only the main `codex` bucket, excluding model-specific
* buckets. All functions are pure for testability. See
@@ -23,6 +30,10 @@
* @module usage-telemetry
*/
import type { PromptCacheStatus } from './types/index.js';
export type { PromptCacheStatus };
/** A single normalized plan-usage window. */
export interface UsageWindow {
/** Percent of the window consumed, 0–100. */
@@ -52,6 +63,24 @@ export interface RawStatuslinePayload {
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
cost?: { total_cost_usd?: number };
model?: { display_name?: string };
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
prompt_cache?: RawPromptCache | null;
}
/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */
export interface RawPromptCache {
warm?: boolean | null;
ttl?: string | null;
/** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */
expires_at?: number | null;
/** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */
recache_tokens_if_cold?: number | null;
misses?: number | null;
/** Cache rebuilds that followed a compaction or a clearing of old tool results (counted at the request after one; not read for the compaction stamp). */
expected_rebuilds?: number | null;
/** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */
hit_ratio?: number | null;
last_miss_cause?: { causes?: unknown } | null;
}
interface RawCodexRateLimitWindow {
@@ -107,6 +136,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
return t;
}
/**
* Normalize the statusline's `prompt_cache` object. Null when the payload has none
* (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not
* a boolean, so the caller can treat the cache state as unknown rather than cold.
*/
export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null {
const pc = data?.prompt_cache;
if (!pc || typeof pc.warm !== 'boolean') return null;
const s: PromptCacheStatus = { warm: pc.warm };
if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl;
if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) {
// Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to
// Infinity or lands outside the Date range after *1000, which renders "cache:until
// NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent.
const ms = Math.round(pc.expires_at * 1000);
if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms;
}
if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) {
s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold));
}
if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) {
s.misses = Math.max(0, Math.round(pc.misses));
}
if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) {
s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds));
}
if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) {
s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio));
}
const causes = pc.last_miss_cause?.causes;
if (Array.isArray(causes)) {
const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60));
if (names.length) s.lastMissCauses = names;
}
return s;
}
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
if (!value || typeof value !== 'object') return null;
@@ -140,6 +206,8 @@ export interface SessionStatus {
inputTokens?: number;
outputTokens?: number;
contextUsedPercentage?: number;
/** Prompt-cache state of the main conversation, when the statusline reports it. */
cache?: PromptCacheStatus;
}
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
@@ -166,19 +234,34 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage);
}
const cache = parsePromptCache(data);
if (cache) s.cache = cache;
return Object.keys(s).length ? s : null;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
* which live in the Codeman header chip. Claude requires a statusLine command to
* emit the rate_limits JSON at all, so this is what that command prints back
* when it has no statusline of the user's own to wrap. With nothing to show it
* returns '' rather than a brand word: a bare `codeman` on the statusline is
* the symptom discussion #405 opened with.
* `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
* once Claude reports it cold or the render happened past `expires_at`. A clock
* time rather than a countdown because the footer only re-renders on statusline
* triggers, so a countdown would sit stale on screen between turns.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
function formatCacheGroup(c: PromptCacheStatus, now: number): string {
if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold';
const d = new Date(c.expiresAt);
return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT
* the plan limits, which live in the Codeman header chip. Claude requires a
* statusLine command to emit the rate_limits JSON at all, so this is what that
* command prints back when it has no statusline of the user's own to wrap. With
* nothing to show it returns '' rather than a brand word: a bare `codeman` on the
* statusline is the symptom discussion #405 opened with. `now` is injectable so
* the cache group is testable.
*/
export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string {
if (!s) return '';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
@@ -187,6 +270,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
if (s.cache) groups.push(formatCacheGroup(s.cache, now));
return groups.length ? groups.join(' ') : '';
}
+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.
*/
options?: ApprovalOption[];
/**
* When true the dialog belongs to a third-party TUI whose keystrokes Codeman
* cannot verify (today: DeepSeek Harness / `hooks: 'supervised'`). The item
* is an ALERT only: the answer route refuses keystrokes, push omits Approve/
* Deny actions, and the UI offers "Open session" instead of answer buttons.
*/
answerInTerminal?: boolean;
}
export interface ApprovalResolvedInfo {
@@ -108,6 +115,11 @@ interface NotePromptArgs {
* opens ALREADY acknowledged: see `notePrompt()`.
*/
watching?: string | null;
/**
* Alert-only item: answer in the terminal, not via Approve/Deny. See
* `ApprovalItem.answerInTerminal`.
*/
answerInTerminal?: boolean;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
}
@@ -261,6 +273,7 @@ export class ApprovalInbox {
toolSummary: args.toolSummary,
message: args.message,
cwd: args.cwd,
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
};
if (args.kind === 'idle' && args.watching) {
item.acknowledgedAt = item.createdAt;
+32
View File
@@ -23,6 +23,7 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
capabilityFromProxyPath,
capabilityFromReferer,
@@ -183,6 +184,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
* an opaque origin, so its asset requests carry no session cookie. Read-only by
* construction (safe methods only), and the Host allowlist still applies. OPTIONS
* is the page's CORS preflight, which registerSecurityHeaders answers itself and
* never hands to a route.
*/
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') return false;
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
}
/**
* A web-tab frame that navigated itself off the proxy prefix (see
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
@@ -368,6 +383,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done();
return;
}
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) {
done();
return;
}
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -507,6 +527,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return;
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) return;
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -711,6 +733,16 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
// are not CORS-checked). Falling through lets the proxy route reply with the
// right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
// A rendered HTML page (`/html-view/<cap>/...`) is opaque-origin for the same
// reason, so a fetch() of its own `data.json` with a non-safelisted header
// preflights with `Origin: null`. Answer it here, read-only: the route serves
// GET and HEAD only, and no credentials are involved (`*` is valid for both).
if (hasValidHtmlViewCapability(req)) {
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, HEAD');
reply.header('Access-Control-Allow-Headers', '*');
reply.header('Access-Control-Max-Age', '600');
}
reply.code(204).send();
done();
return;
+105 -30
View File
@@ -1,25 +1,99 @@
/**
* @fileoverview Periodic GC for paste-image files.
* @fileoverview Periodic GC for prompt-upload files, and the one place that
* names the directories they live in.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* under {workingDir}/.codeman-uploads/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's image dir on an interval.
* older than MAX_AGE_MS from each live session's upload dirs on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* image dir.
* upload dir.
*/
import fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join, resolve, sep } from 'node:path';
import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
/**
* Where a prompt upload is written, relative to the session's working
* directory. IN the workspace, because that is the only path that resolves
* identically for a local agent and a container (only the workspace is
* bind-mounted, at the same absolute path); hidden, so it stays out of
* `git status` and the agent's view of the repository; FLAT, because a nested
* `<workspace>/.codeman/` is Codeman's own data dir when the workspace is the
* home directory; and self-ignoring, through a `.gitignore` of `*` the route
* writes once.
*/
export const UPLOADS_DIR = '.codeman-uploads';
/** Where uploads landed before the move: written to by nothing, readable for one release. */
export const LEGACY_UPLOADS_DIR = '.claude-images';
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
/**
* Every directory a session's uploads sit in, current first. Both consumers
* act on what this returns, the hourly sweep and the recursive delete in
* cleanupSession(), so it lists only REAL directories (a link planted by a
* workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
* not one; readdir follows a link to a directory), none that is or contains
* this instance's data dir (a home workspace reaches it under a contrived
* instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
* inside one; a directory strictly below the data dir only ever holds uploads
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
* never be collected), and nothing for a remote (SSH) session, whose
* workingDir is the remote path and would name a same-named LOCAL directory
* here. The working directory is a user-chosen path, so it is probed BOUNDED
* first (#516): one on a mount that stopped answering reads `unknown` and is
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
* acting on one path at the user's request, passes `pastCap`. The check is
* made when listing: a same-user process that swaps a listed directory for a
* link afterwards is accepted, since it already writes anywhere this process
* can.
*/
export async function uploadDirs(
session: { workingDir: string; remote?: unknown },
probe: PathProbeOptions = {}
): Promise<string[]> {
if (session.remote) return [];
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
const dataDir = await realDir(getDataDir());
const dirs: string[] = [];
for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
if (!(await isRealDir(dir))) continue;
const real = await realDir(dir);
if (real === dataDir || dataDir.startsWith(real + sep)) continue;
dirs.push(dir);
}
return dirs;
}
/** lstat, so a symlink is not a directory, whatever it points at. */
async function isRealDir(p: string): Promise<boolean> {
try {
return (await fs.lstat(p)).isDirectory();
} catch {
return false;
}
}
/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
async function realDir(dir: string): Promise<string> {
try {
return await fs.realpath(dir);
} catch {
return resolve(dir);
}
}
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
@@ -28,26 +102,27 @@ export async function sweepPasteImagesOnce(
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
for (const dir of await uploadDirs(session)) {
let entries: string[];
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
entries = await fs.readdir(dir);
} catch {
// best-effort: skip permission/race errors silently
continue; // gone since listed — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
}
@@ -55,7 +130,7 @@ export async function sweepPasteImagesOnce(
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* The path two sessions must share to share an upload dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
@@ -68,7 +143,7 @@ function canonicalDir(dir: string): string {
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
@@ -76,11 +151,11 @@ export interface PasteImageDirUser {
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
* Does another live session still use this working directory's upload dirs?
* Deleting a session removes them (`uploadDirs()`) recursively, and several
* sessions routinely share one case directory, so without this check closing
* one session deletes the pasted images a sibling in the same case still
* refers to.
*
* Two kinds of sibling count as live:
*
+8 -1
View File
@@ -5,10 +5,17 @@
import type { Session } from '../../session.js';
import type { SessionState } from '../../types.js';
import type { TabLayout } from '../../tab-layout.js';
import type { SessionPlacementHint } from '../../tab-layout-service.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): Promise<void>;
/**
* Register a created session with the owner's tab layout. `placement.tabGroupId`
* puts it at the end of that group in the same layout write (ignored when the
* group is not in the owner's layout). Resolves to the committed layout.
*/
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
+702 -52
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -261,6 +261,10 @@ Object.assign(CodemanApp.prototype, {
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
`</div>`;
} else if (item.answerInTerminal) {
// Supervised-hook CLIs (DeepSeek Harness): keystrokes are unverifiable, so
// Approve/Deny would hit the answer route and fail. Offer only Open session.
actions = `<span class="approval-terminal-hint" data-i18n-skip>Answer in the terminal</span>`;
} else if (item.options && item.options.length) {
actions = item.options
.map(
+662 -34
View File
@@ -116,6 +116,21 @@ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / bro
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
// A shell's automatic loads (tab select, drop recovery, a tile's load) read
// tmux's RENDERED history, never the raw byte recording: that recording holds
// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
// the moment, and replayed into a terminal of another height each repaint
// scrolls its top row into scrollback, so one line shows up dozens of times.
// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
// the older lines as before. `alt=recording`: a pane in the alternate screen (a
// nested tmux, as in remote SSH and Docker cases, but also vim or less while
// open) gains no tmux history, so the server loads it the old way, visible
// frame over the tailed byte recording.
// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
const SHELL_LOAD_HISTORY_LINES = 10000;
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
@@ -709,6 +724,45 @@ function computeConnectionLossUi(input) {
};
}
// Session Options → Respawn → "Prompt cache": one sentence of the main
// conversation's cache state as Claude Code reports it on the statusline
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
// because the row refreshes on SSE, not on a timer, and a countdown would sit
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
// session's `statusLineExporter`: a launch without the exporter never reports, so
// it says why instead of "not reported yet" forever.
const PROMPT_CACHE_NOT_COLLECTED = {
'collection-off':
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
'workspace-statusline':
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
remote: 'Not collected for remote and Docker sessions.',
};
function formatPromptCacheStatus(cache, now, statusLine) {
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
if (!cache || typeof cache.warm !== 'boolean') {
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
}
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
const stake = cache.recacheTokensIfCold;
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
const parts = [];
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
} else {
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
}
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
// request; naming when it happened explains the gap. Never an alert.
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
return parts.join(' ');
}
// SSE staleness policy: is this stream a zombie?
//
// An EventSource that stops delivering does not always error. A proxy that
@@ -1020,6 +1074,56 @@ function tabClusterNameSplit(name, label) {
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
/**
* Session-list search: the vertical rail's search box and the sidebar's filter
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
* Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
* Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
* @param {unknown} query
* @returns {string} the needle, '' when there is nothing to search for
*/
function tabSearchNeedle(query) {
return typeof query === 'string' ? query.trim().toLowerCase() : '';
}
/**
* Which rows a search hides. Pure: the caller reads the rows off the list it
* rendered and applies the result as classes, so the list itself (grouping,
* order, Alt+N badges) is never rebuilt or reordered by a search.
*
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
* view filter). It counts toward its section, so its group stays on screen with
* it, but not toward `matchCount`.
*
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
* in list order; `section` is the row's group or case box, null/undefined for none.
* @param {unknown} query
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
* `counts` is the rows left showing per section (kept rows included), every
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
*/
function filterTabSearchRows(rows, query) {
const needle = tabSearchNeedle(query);
const hidden = new Set();
const counts = new Map();
let matchCount = 0;
for (const row of Array.isArray(rows) ? rows : []) {
const hasSection = row.section !== null && row.section !== undefined;
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
const matches = !needle || text.includes(needle);
if (!matches && row.keep !== true) {
hidden.add(row.key);
continue;
}
if (matches) matchCount++;
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
}
return { active: needle.length > 0, hidden, counts, matchCount };
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1301,6 +1405,63 @@ function cleanCopiedSelection(text, options) {
return lines.join('\n');
}
// ── Markdown heading anchors ────────────────────────────────────────────────
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
/**
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
*/
function markdownHeadingSlug(text) {
return String(text ?? '')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
.replace(/ /g, '-');
}
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
function assignMarkdownHeadingAnchors(root) {
const used = new Set();
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const base = markdownHeadingSlug(heading.textContent);
let slug = base;
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
used.add(slug);
heading.dataset.mdAnchor = slug;
}
}
/**
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
* only (never `document.getElementById`, which could find an app element of the same name).
*/
function findMarkdownAnchorTarget(root, href) {
let fragment = String(href ?? '').replace(/^#/, '');
try {
fragment = decodeURIComponent(fragment);
} catch {
/* a malformed escape: use it as written */
}
if (!fragment) return root;
assignMarkdownHeadingAnchors(root);
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
}
for (const el of root.querySelectorAll('[id]')) {
if (el.id === fragment) return el;
}
return null;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -1340,6 +1501,9 @@ if (typeof window !== 'undefined') {
compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
anchor: sessionActivityAnchor,
@@ -1358,6 +1522,10 @@ if (typeof window !== 'undefined') {
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
window.CodemanTabSearch = {
needle: tabSearchNeedle,
filter: filterTabSearchRows,
};
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1370,6 +1538,11 @@ if (typeof window !== 'undefined') {
window.CodemanCopySelection = {
clean: cleanCopiedSelection,
};
window.CodemanMarkdownAnchors = {
slug: markdownHeadingSlug,
assign: assignMarkdownHeadingAnchors,
find: findMarkdownAnchorTarget,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
@@ -1391,6 +1564,15 @@ function scheduleBackground(fn) {
// but server-sent terminal buffers may still contain markers from Claude CLI.
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
// OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
// xterm parses it after chunkedTerminalWrite has already released the load
// gate, so an OSC 52 left in would re-copy old text on every tab switch. The
// 8-bit C1 forms (OSC \x9d, ST \x9c) too: xterm.js still parses those.
const OSC52_STRIP_RE = /(?:\x1b\]|\x9d)52;[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g;
// The same OSC 52 text arriving again within this window is one copy delivered
// several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
const OSC52_DEDUPE_MS = 1000;
// Built-in respawn configuration presets
const BUILTIN_RESPAWN_PRESETS = [
{
@@ -1516,6 +1698,7 @@ const SSE_EVENTS = {
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
SESSION_PROMPT_CACHE: 'session:promptCache',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
@@ -1772,7 +1955,7 @@ function formatHistoryBytes(bytes) {
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* paneHistoryLines?: number|null}} state
* paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
@@ -1806,10 +1989,17 @@ function computeHistoryTruncationNotice(state = {}) {
}
// 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.`
: `${formatHistoryBytes(dropped)} more may still be retained.`;
: `${remainder} may still be retained.`;
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${more}`,
@@ -1857,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1869,13 +2059,18 @@ function absoluteFilePathPattern() {
* points at goes to the tail/log viewer, which is the right home for a growing
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
*
* Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
* document to read, so the preview renders it (its MD pill gives the source)
* instead of the log viewer streaming it as raw text. Other text (.log, .txt,
* .json, code) keeps the log viewer, which follows a file still being written.
*
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
* (src/attachment-registry.ts, the single source) — they diverged once and an
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus md markdown').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -1884,6 +2079,155 @@ function previewsInFileViewer(filePath) {
return FILE_PREVIEW_EXTENSIONS.has(ext);
}
/**
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
* or a bare `look_montage.png`. Same extension list and body as
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
* equal).
*
* Without a known root to anchor on, the START is what has to be unambiguous:
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
* a URL. The path itself is group 2. Its first character is never `/`: absolute
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
* the file, not the punctuation around it.
*
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* then `~/` and relative ones, then folder paths — each only where it overlaps
* neither an earlier one nor a URL.
*
* The one entry point for both consumers (terminal link provider, response
* viewer), so a path form is clickable in both or in neither.
*
* @param {string} text
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
* `text`; `folder` marks a folder path (folder grid) rather than a file.
*/
function findFilePathLinks(text) {
const value = String(text || '');
const found = [];
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
const absolute = absoluteFilePathPattern();
while ((match = absolute.exec(value)) !== null) {
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
}
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
while ((match = relative.exec(value)) !== null) {
const path = match[2];
const index = match.index + match[1].length;
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index });
}
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
while ((match = folders.exec(value)) !== null) {
const path = folderLinkPath(match[2]);
const index = match.index + match[1].length;
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index, folder: true });
}
return found.sort((a, b) => a.index - b.index);
}
/**
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
* trailing slash (`builds/captures/`). Same boundary in front as the relative
* file pattern (group 1), the candidate itself is group 2 and goes through
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
* File links are found first and win any overlap. A folder path ends at `:`,
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
* the `://` lookahead stops at the next colon instead of rescanning the token from
* every one (quadratic on `a:a:a:…//`).
*
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const FOLDER_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
/**
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
*
* Sentence punctuation after the path is not part of it. A path ending in `/` is
* a folder; otherwise only a rooted or `~/` path whose last segment has no
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
* without a slash at the end is prose, not a folder.
*/
function folderLinkPath(candidate) {
// `$` and `#` too: a shell prompt (`user@host:~/proj$ `, `root@c:/app# `) ends
// its cwd with one. A folder whose name really ends in one links one short.
const path = String(candidate || '').replace(/[.,:;!?'"$#]+$/, '');
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
const last = path.slice(path.lastIndexOf('/') + 1);
return /^\.?[^.]+$/.test(last) ? path : null;
}
/**
* Where a clicked path points, as the preview routes need it.
*
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
* session's HOST, which only the server knows (a remote case's home is on the
* remote machine), so the server expands it. A relative path is resolved
* against the session's working directory, `.` and `..` collapsed, so a
* `../other/x.png` ends up outside the workspace and routes like any other
* external path. Without a working directory it is returned as is.
*
* @param {string} filePath
* @param {string} [workingDir]
* @returns {string}
*/
function resolveLinkedFilePath(filePath, workingDir) {
const path = String(filePath || '');
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
const parts = [];
for (const part of `${workingDir}/${path}`.split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return `/${parts.join('/')}`;
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
* the two equal). The terminal sends a click on one to the preview, never to
* the log viewer, and the preview renders it with a Page pill back to source.
*/
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
function rendersAsPage(filePath) {
const ext = String(filePath || '').split('.').pop().toLowerCase();
return HTML_PAGE_EXTENSIONS.has(ext);
}
/**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -1988,6 +2332,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
return { startRow, endRow, text, offsetToCell, cellToOffset };
}
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
/**
* Whether the line `nextText` carries on a file path `prevText` broke off.
*
* Answers `'join'` when the path ends (reaches a known extension) on the next
* line, `'chain'` when the next line is nothing but more of the path (a path
* spanning three or more lines; only worth keeping once a later line ends it),
* and `null` otherwise. The broken-off token must already look like a path in
* progress: it contains a `/`, ends on a path character and does not yet end
* on an extension. A next line that opens a NEW path is never glued on: a rooted
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
* top directory the broken token did (the next entry of a listing).
*/
function pathContinuation(prevText, nextText) {
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
const next = String(nextText || '').replace(/^\s+/, '');
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
return head === next.replace(/\s+$/, '') ? 'chain' : null;
}
/**
* {@link terminalLogicalLine}, extended across the line breaks a program puts
* INSIDE a file path. Used by the link provider only; touch selection keeps the
* plain logical line.
*
* Claude Code wraps its tool output itself, a few columns short of the terminal
* edge, so neither continuation signal of terminalLogicalLine fires (no
* `isWrapped`, the row does not reach the last column) and a long path was only
* linked up to the break:
*
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
* 026-10-10/ghost1b_gameplay.png err=0 …
*
* A following logical line is glued on (its indent dropped, cell mapping kept
* exact) when {@link pathContinuation} says it carries the path on and the row it
* broke off is at least as long as the row it continues on. Bounded by the same
* `maxRows` as the logical line.
*
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
*/
function terminalPathLine(buffer, row, cols, maxRows) {
const base = terminalLogicalLine(buffer, row, cols, maxRows);
if (!base) return null;
const bound = Math.max(1, maxRows || 12);
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
const rowsOf = (line) => line.endRow - line.startRow + 1;
// A program breaking a long token fills the row to its wrap width, and no
// later row of that block is wider. So the row a path broke off is at least as
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
// to a longer ` file.png written` below it.
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
const continues = (prev, next, prevText) =>
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
const extend = (first) => {
const parts = [first];
let text = first.text;
let rows = rowsOf(first);
let confirmed = 1;
for (;;) {
const next = lineAt(parts[parts.length - 1].endRow + 1);
if (!next || rows + rowsOf(next) > bound) break;
const kind = continues(parts[parts.length - 1], next, text);
if (!kind) break;
parts.push(next);
text += next.text.replace(/^\s+/, '');
rows += rowsOf(next);
if (kind === 'join') confirmed = parts.length;
}
return parts.slice(0, confirmed);
};
// The hovered row may be the second or third line of a broken path: walk back
// to where it could start, then let the forward pass decide what really joins.
let first = base;
let rows = rowsOf(base);
while (first.startRow > 0) {
const prev = lineAt(first.startRow - 1);
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
first = prev;
rows += rowsOf(prev);
}
let parts = extend(first);
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
if (parts.length === 1) return parts[0];
const segments = [];
let text = '';
for (const [i, part] of parts.entries()) {
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
segments.push({ part, textStart: text.length, skip });
text += part.text.slice(skip);
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
}
return segments[0].part.offsetToCell(offset);
};
const cellToOffset = (targetRow, targetCol) => {
for (const seg of segments) {
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
const offset = seg.part.cellToOffset(targetRow, targetCol);
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
}
return -1;
};
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
@@ -2110,11 +2582,11 @@ function tileGridCapacity({ width, height }) {
}
/**
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
* deleted, detached and duplicate ids are dropped, the list is capped at
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
* (or its JSON) gives null.
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
* content) made safe to apply: unknown, deleted, detached and duplicate ids
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
* Anything that is not a v1 object (or its JSON) gives null.
*
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
@@ -2122,11 +2594,20 @@ function tileGridCapacity({ width, height }) {
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
*
* `freed` names the cells whose session no longer exists (or was popped out
* to its own window) since the grid was stored: the ranking fills those first
* when the grid comes back (restoreTileGridCells). A hole the user left empty
* is not freed. `count` is how many tiles the grid had after the user's own
* last change (a session that went away by itself does not lower it), so the
* grid comes back to that many when there are sessions to fill it; a value
* stored before it existed, or a malformed one, reads as the number of
* sessions the stored cells name.
*
* @param {unknown} raw - the parsed value, or the stored JSON string
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
*/
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
let value = raw;
@@ -2137,11 +2618,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
const ids = [];
const cells = [];
const freed = [];
const named = [];
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
const keep =
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
ids.length < TILE_GRID_MAX;
const isId = typeof id === 'string' && id !== '';
const present = isId && live.has(id) && !detachedIds?.has?.(id);
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
if (keep) ids.push(id);
// Its session went away since: the cell is freed for the ranking to fill.
if (isId && !present && !named.includes(id)) freed.push(cells.length);
if (isId && !named.includes(id)) named.push(id);
// A malformed entry (not a string, not null) is a hole too.
cells.push(keep ? id : null);
}
@@ -2149,11 +2635,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
};
const count =
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
? value.count
: Math.min(named.length, TILE_GRID_MAX);
return {
v: 1,
open: value.open === true && ids.length > 0,
ids,
cells,
freed,
count,
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
colFr: fractions(value.colFr),
@@ -2161,6 +2653,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
};
}
/**
* A stored grid as it comes back (the Tiles button, a page reload): its cells
* exactly as stored, holes the user left included, and its focus (or the tile
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
* that went away since, or by themselves while it was open) does it fill, from
* `ranked` (best first, never a session already in it): the freed cells first,
* then the other empty cells, in reading order; more than the cells hold join
* after them (the shape grows when the grid lays them out, reformTileCells).
* A cell stays empty only when no other session is left to place. Never
* trimmed to the window: a grid larger than the window fits shows its focused
* tile alone until the window fits it again, and the arrangement stays.
*
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
*/
function restoreTileGridCells(stored, ranked) {
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
const cells = [];
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
const tiles = cells.filter(Boolean);
if (tiles.length === 0) return null;
const focusedId =
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
const fillers = [];
for (const id of ranked || []) {
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
}
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
const empty = [];
cells.forEach((id, k) => {
if (id === null) empty.push(k);
});
// Freed cells first, each group in reading order.
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
let count = tiles.length;
for (const k of empty) {
if (count >= target || fillers.length === 0) break;
cells[k] = fillers.shift();
count++;
}
const extra = fillers.slice(0, Math.max(0, target - count));
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
}
/**
* New track fractions after a divider drag (grid-template `fr` values): the two
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
@@ -2193,7 +2732,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
/**
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
* ones a count fills a grid with), in the order given (tab order, or the
* ranking): live ones only, never a session popped out to
* its own window (that window owns its PTY size). A session with no PTY
* attached IS offered: its tile shows the Attach overlay.
*
@@ -2207,44 +2747,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
for (const id of sessionOrder) {
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
if (!session) continue;
if (!session || result.some((r) => r.id === id)) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
// Ranking groups (rankTileSessions): working first, then the sessions waiting
// on the user, then everything else.
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
/**
* The stamp a session is ranked by inside its group. A WORKING session keys
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
* about once a second, so its last-activity stamp is always "now", and one
* that never submitted would otherwise claim the head of the group. 0 means
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
* the last byte the pane printed, i.e. when it went quiet).
*/
function tileRankStamp(row) {
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
return sessionActivityAnchor(row);
}
/**
* Which open sessions the tile grid shows when nobody said which (the Tiles
* button with no stored grid to bring back, and every place the grid fills a
* tile on its own: a count picked in its menu, a freed cell), best first
* (owner request: "prefer to load in tiles that are working and then the most
* recent, so the oldest don't get opened"):
* 1. WORKING, the most recently started turn first;
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
* permission or question dialog; `waiting`: a finished turn not seen
* yet), most recent first;
* 3. then every other one (idle, done, error), most recently active first.
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
* the result never shuffles. The states are the home screens' own
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
* order differs: the home screens put the longest-running turn first, the grid
* the most recent.
*
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
*
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
* @returns {string[]} the ids, best first, each once
*/
function rankTileSessions(rows) {
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
list.sort((a, b) => {
const byGroup = group(a) - group(b);
if (byGroup !== 0) return byGroup;
const atA = tileRankStamp(a);
const atB = tileRankStamp(b);
if (atA !== atB) {
if (!atA) return 1;
if (!atB) return -1;
return atB - atA;
}
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const ids = [];
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
return ids;
}
/**
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
* (owner decision 8). In order:
* a. the grid this tab last had (`stored`, already sanitized: live, not
* detached, at most the cap), if any of its sessions survive;
* detached, at most the cap), if any of its sessions survive, exactly
* as it was (restoreTileGridCells: its cells and holes, a cell its
* session freed filled from the ranking);
* b. else an open split's two sessions, Pane A focused;
* c. else the open sessions in tab order (buildTilePickerSessions: no
* detached ones), up to `limit`, the active session always among them and focused
* (when it sits past the limit, the first `limit - 1` others come with it).
* c. else the open sessions in `ranked` order (rankTileSessions: working,
* then needing input, then the most recent; tab order when no ranking is
* given), detached ones never, up to `limit`, the active session always
* among them and focused (when it ranks past the limit, the first
* `limit - 1` others come with it).
* Null when there is nothing to open.
*
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
* focused: string|null, zoomed: string|null}|null,
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
* focusedId: string|null}|null}
*/
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
if (stored?.ids?.length) {
const focus = stored.zoomed || stored.focused;
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
}
function tileGridOpenSet({
stored = null,
split = null,
ranked = null,
sessions,
sessionOrder,
detachedIds,
activeId = null,
limit,
}) {
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
(c) => c.id
);
const restored = stored ? restoreTileGridCells(stored, all) : null;
if (restored) return { source: 'stored', ...restored };
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
const pair = (split || []).filter(usable);
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
if (all.length === 0) return null;
let ids = all.slice(0, max);
if (all.includes(activeId) && !ids.includes(activeId)) {
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
}
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
}
/**
@@ -2264,8 +2881,9 @@ function sanitizeTileCount(raw) {
* `base` (what the grid would open, or what an open grid shows, in its order)
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
* Fewer sessions than `n` give fewer tiles.
*
* @param {string[]} base
* @param {string[]} all
@@ -2754,8 +3372,16 @@ function describeSessionHarness(session, catalog) {
if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
window.CodemanFilePaths = {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
@@ -2774,6 +3400,8 @@ if (typeof window !== 'undefined') {
fitTileCells,
cycleTile,
tileGridOpenSet,
restoreTileGridCells,
rankTileSessions,
sanitizeTileCount,
tileGridSetForCount,
tileCellCols,
+490 -43
View File
@@ -1,12 +1,13 @@
/**
* @fileoverview Entrance animations for the four things that appear when work
* @fileoverview Entrance animations for the things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
* that set all five to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Appearance → Entrance Animations.
* → Animations.
*
* Four constraints shape the design:
*
@@ -34,13 +35,28 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
* before) and switched on in App Settings → Animations → Tile Animations, or
* preset by a theme. A styled tile plays in two beats that combine two
* surfaces. The FRAME enters as it mounts, in its own style
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
* pane). The load queue serves one capture at a time, so
* the screens light up one after another, the focused tile first. The frame
* styles move transform and opacity only (six tiles animate at once; a blur
* belongs to the serialized screen beat), and each has its own way out on the
* closing grid's still copy. A reload restores with `settle` whatever the
* setting.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
* replay, a window and a line, and replays the tile grid in place, so styles
* can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/**
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
* still fits once at its final size (#464). `stagger` is the default gap
* between tiles and `order` the default cascade: `reading` (row by row),
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
* tile). `from`: the tile is measured against a source after layout (its tab,
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
*/
// prettier-ignore
const TILE_ANIM_STYLES = [
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
];
/** Cascade orders for the tile grid; `auto` is each style's own. */
const TILE_ANIM_ORDERS = [
{ key: 'auto', label: 'Style default' },
{ key: 'reading', label: 'Reading order' },
{ key: 'wave', label: 'Diagonal wave' },
{ key: 'ripple', label: 'Ripple from focus' },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
const TILE_EXIT_STAGGER_MS = 35;
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
];
/**
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
* when it is picked, but the tile style is its own setting (App Settings →
* Animations → Tile Animations, off by default), so changing it afterwards
* does not turn the theme into "Custom".
*/
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
* hook short-circuits on its first line. Opt in via App Settings → Animations,
* which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
const TILE_ANIM_DEFAULT = 'settle';
const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
tile: 'codeman:tileAnim',
tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
// were a surface gives them nothing new.
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
setTileAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
},
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
setTileAnimOrder(key, { persist = true } = {}) {
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
this._syncAnimLab?.();
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
/** The theme matching the four current styles, or 'custom' for a lab mix. */
/** The current style of each surface, keyed as ANIM_SURFACES. */
_currentAnimStyles() {
return {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
tile: this._tileAnimStyle,
};
},
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
const current = this._currentAnimStyles();
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
return match ? match.key : 'custom';
},
// ── App Settings picker ───────────────────────────────────────────────────
// ── App Settings → Animations ─────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
if (sel) {
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
}
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
// tracking, so this closes it as Cancel does (the row says so).
const labBtn = document.getElementById('appSettingsOpenAnimLab');
if (labBtn && !labBtn.dataset.bound) {
labBtn.dataset.bound = '1';
labBtn.addEventListener('click', () => {
this.closeAppSettings?.();
this.openAnimLab();
});
}
// Tile Animations: its own row, off (`settle`) by default. A theme picked
// above presets it; picked here, it applies to tiles alone.
const tileSel = document.getElementById('appSettingsTileAnim');
if (tileSel) {
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
if (!tileSel.dataset.bound) {
tileSel.dataset.bound = '1';
tileSel.addEventListener('change', () => {
this.setTileAnimStyle(tileSel.value);
this._syncEntranceAnimSetting();
});
}
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
_tileAnimDuration() {
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -501,6 +618,317 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Tile grid ─────────────────────────────────────────────────────────────
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
tileEntranceStyle() {
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
},
/**
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
* until the next frame, when its cell is final and _runTileEntrances can
* order it and measure it against its source. `setBackstop(ms)` arms the
* mount's own timer that ends the entrance should animationend never come
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
* so a frame that never comes cannot strand a tile invisible.
*/
_stageTileEntrance(el, sessionId, setBackstop) {
el.classList.add('tile--enter-themed', 'tile--enter-hold');
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
setBackstop(4000);
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
},
/**
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
* then a stored grid moves its tiles back). Each held tile gets its delay
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
* its tab or the Tiles button (FLIP: transform only, so the fit it already
* did at its real size stands). `beam` draws its lines instead.
*/
_runTileEntrances() {
this._tileEnterRaf = 0;
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
this._tileEnterQueue = [];
if (queue.length === 0) return;
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
const speed = this._animSpeed || 1;
const stagger = def.stagger / speed;
const duration = this._tileAnimDuration();
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
const ranks = this._tileEnterRanks(
queue.map((q) => q.sessionId),
order
);
const beams = [];
queue.forEach((item, k) => {
const { el, sessionId } = item;
const delay = ranks[k] * stagger;
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
if (def.from) {
const to = el.getBoundingClientRect();
const from = this._tileSourceRect(def.from, sessionId);
if (from && to.width > 0 && to.height > 0) {
if (def.key === 'beam') beams.push({ from, to, delay });
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
}
}
el.classList.remove('tile--enter-hold');
// When the frame lands, for a screen whose content arrives earlier.
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
item.setBackstop(delay + hold + duration + 900);
});
if (beams.length > 0) this._drawTileBeams(beams);
},
/**
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
* the focused tile. Equal keys share a step, so a diagonal lands together.
*/
_tileEnterRanks(ids, order) {
const grid = this._tileGrid;
const cols = Math.max(1, grid?.cols || 1);
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
const pos = ids.map((id, k) => {
const c = cellOf(id);
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
});
let keys;
if (order === 'wave') {
keys = pos.map((p) => p.row + p.col);
} else if (order === 'ripple') {
const f = cellOf(grid?.focusedId);
const fr = f < 0 ? 0 : Math.floor(f / cols);
const fc = f < 0 ? 0 : f % cols;
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
} else {
keys = pos.map((p) => p.cell);
}
const steps = [...new Set(keys)].sort((a, b) => a - b);
return keys.map((v) => steps.indexOf(v));
},
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
_tileSourceRect(kind, sessionId) {
const visible = (node) => {
const r = node?.getBoundingClientRect?.();
if (!r || !(r.width > 0 && r.height > 0)) return null;
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
};
if (kind === 'tab') {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
const r = visible(tab);
if (r) return r;
}
return visible(document.querySelector('.btn-tile-grid'));
},
/**
* The transform that puts a tile laid out at `to` onto `from`, as custom
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
* size for `fly` (it grows out of it), a small card turned a little for
* `deal`.
*/
_setTileFlight(el, from, to, kind, rank, prefix) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let sx;
let sy;
let rot = 0;
if (kind === 'deal') {
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
} else {
sx = clamp(from.width / to.width, 0.02, 1);
sy = clamp(from.height / to.height, 0.02, 1);
}
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
},
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
_flashTileSourceTab(sessionId, delay) {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
if (!tab) return;
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
tab.classList.remove('tab-launch');
void tab.offsetWidth;
tab.classList.add('tab-launch');
const onEnd = (e) => {
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
};
const done = () => {
clearTimeout(timer);
tab.removeEventListener('animationend', onEnd);
tab.classList.remove('tab-launch');
tab.style.removeProperty('--tab-launch-delay');
};
const timer = setTimeout(done, delay + 900);
tab.addEventListener('animationend', onEnd);
},
/**
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
* the middle of its tile, in the connection-line look, with a packet riding it
* when the line style is `packet`; the tile materializes as it lands. Its
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
* The overlay goes once every line has faded.
*/
_drawTileBeams(beams) {
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('tileBeamLines');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'tileBeamLines';
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(svg);
}
const packet = this._lineAnimStyle === 'packet';
let last = 0;
for (const { from, to, delay } of beams) {
const x1 = from.left + from.width / 2;
const y1 = from.bottom;
// Into the tile's middle: its top edge sits right under the tab strip,
// so a beam aimed there ran sideways along the strip instead of down.
const x2 = to.left + to.width / 2;
const y2 = to.top + to.height / 2;
const midY = (y1 + y2) / 2;
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line tile-beam-line');
svg.appendChild(path);
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
if (packet) {
const dot = path.cloneNode(false);
dot.setAttribute('class', 'connection-line-packet');
svg.appendChild(dot);
}
last = Math.max(last, delay);
}
clearTimeout(this._tileBeamTimer);
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
},
/**
* A tile's screen lights up when its first capture lands (tile-grid.js load
* queue), in the terminal pane's style: the same keyframes as the main pane,
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
* tile at a time, since the queue serves one capture at a time), so the
* xterm inside keeps its size and its fit.
*/
playTileScreenEntrance(body) {
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
body._codemanScreenDone?.();
// Content that lands while the frame is still flying in waits (held at
// its first keyframe, so hidden) until the frame has nearly landed: two
// beats, frame then screen, rather than both at once.
const frame = body.closest?.('.tile');
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
body.classList.remove('term-enter');
void body.offsetWidth;
body.classList.add('term-enter');
let timer = null;
const done = (e) => {
if (e && (e.target !== body || e.pseudoElement)) return;
clearTimeout(timer);
body.removeEventListener('animationend', done);
body.removeEventListener('animationcancel', done);
body.classList.remove('term-enter');
body.style.removeProperty('--tile-screen-delay');
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
};
body._codemanScreenDone = done;
body.addEventListener('animationend', done);
body.addEventListener('animationcancel', done);
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
},
/**
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
* frame style's own way: `fly` back into each tab, `deal` gathered into the
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
* reading order, so the last one ends last (the layer goes on its
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
* covers tiles that stay. Returns how long the slowest copy takes, for the
* layer's fallback timer, or 0 for the default fade.
*/
_stageTileExit(copies, { now = false } = {}) {
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
const ms = TILE_EXIT_MS[style];
if (now || !ms || copies.length === 0) return 0;
const speed = this._animSpeed || 1;
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
copies.forEach(({ ghost, el, sessionId }, k) => {
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
if (style !== 'fly' && style !== 'deal') return;
const at = el.getBoundingClientRect();
const target = this._tileSourceRect(def.from, sessionId);
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
});
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
},
/**
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
* light up again in queue order): no remount, reconnect or resize. With the
* grid closed it opens it, the real path.
*/
_demoTiles() {
const grid = this._tileGrid;
if (!grid?.open) {
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
return;
}
if (!this._tileMotionAllowed?.()) return;
const ids = grid.ids.slice();
ids.forEach((id, k) => {
const entry = grid.tiles.get(id);
if (entry) this._replayTileEntrance?.(entry.el, id, k);
});
// The screens, as the load queue would land them: focused first, then reading order.
const speed = this._animSpeed || 1;
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
clearTimeout(this._tileDemoTimer);
const timers = order.map((id, k) =>
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
);
this._tileDemoTimers?.forEach(clearTimeout);
this._tileDemoTimers = timers;
},
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
_demoTilesRoundTrip() {
if (!this._tileGrid?.open) {
this._demoTiles();
return;
}
this.toggleTileGrid?.();
clearTimeout(this._tileDemoTimer);
this._tileDemoTimer = setTimeout(
() => {
if (!this._tileGrid?.open) this.toggleTileGrid?.();
},
1400 / (this._animSpeed || 1)
);
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
<label class="anim-lab-select">Tile order
<select data-select="tileOrder">
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
</select>
</label>
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
@@ -552,6 +986,11 @@ Object.assign(CodemanApp.prototype, {
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<div class="anim-lab-demo">
<span>Tiles</span>
<button type="button" data-demo="tiles">Replay</button>
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else if (attr === 'tile') this.setTileAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
this._syncEntranceAnimSetting?.();
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
});
});
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
this.setTileAnimOrder(e.target.value);
this.demoEntrance('tiles');
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
const current = this._currentAnimStyles();
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
});
const order = panel.querySelector('select[data-select="tileOrder"]');
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'} what */
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
if (what === 'tiles') return this._demoTiles();
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
// With the grid open, the "pane" is every tile's screen.
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
+112 -6
View File
@@ -4448,6 +4448,7 @@ var GestureController = class {
// packages/gesture-control/src/codeman/entry.ts
var TAB_SELECTOR = ".session-tab";
var TILE_SELECTOR = "#tileGrid .tile";
var PANEL_SELECTOR = ".cg-float";
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
var DOCK_SELECTOR = ".session-tabs";
@@ -4457,6 +4458,8 @@ var FLOAT_W = 640;
var FLOAT_H = 420;
var DETACH_PULL_PX = 70;
var TAP_CANCEL_PX = 45;
var TILE_MOVE_PX = 40;
var TILE_GHOST_W = 280;
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
var GestureBridge = class {
constructor() {
@@ -4533,7 +4536,7 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on \u2014 pinch a tab, window, or button";
this.status.textContent = "on: pinch a tab, tile, window, or button";
} catch (err) {
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
@@ -4588,6 +4591,30 @@ var GestureBridge = class {
this.status.textContent = "moving window";
return;
}
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add("tile--dragging");
this.grabs.set(hand, {
kind: "tile",
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
ox: x2,
oy: y2,
armed: false,
target: null,
out: false
});
this.positionGhost(ghost, x2, y2);
this.status.textContent = "moving tile";
return;
}
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
const id = tab?.dataset.id;
if (tab && id) {
@@ -4599,7 +4626,7 @@ var GestureBridge = class {
ghost.style.height = `${rect.height}px`;
document.body.append(ghost);
tab.classList.add("cg-grabbed");
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
this.positionGhost(ghost, x2, y2);
return;
}
@@ -4613,13 +4640,30 @@ var GestureBridge = class {
}
onDrag(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.positionGhost(grab.ghost, x2, y2);
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
grab.out = out;
grab.ghost.classList.toggle("cg-armed", !!target);
grab.ghost.classList.toggle("cg-out", out);
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
return;
}
if (grab?.kind === "tab") {
this.positionGhost(grab.ghost, x2, y2);
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
if (pulled !== grab.armed) {
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
grab.armed = pulled;
grab.ghost.classList.toggle("cg-armed", pulled);
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
}
return;
}
@@ -4641,16 +4685,40 @@ var GestureBridge = class {
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove("cg-tap-armed");
this.taps.delete(hand);
this.status.textContent = "on \u2014 pinch a tab, window, or button";
this.status.textContent = "on: pinch a tab, tile, window, or button";
}
}
onDrop(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash("cancelled");
return;
}
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
} else {
this.flash("cancelled");
}
return;
}
if (grab?.kind === "tab") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
if (grab.armed) this.floatPanel(grab.id, x2, y2);
this.setTileTarget(grab, null);
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
else this.flash("cancelled");
return;
}
@@ -4775,11 +4843,41 @@ var GestureBridge = class {
ghost.style.left = `${x2}px`;
ghost.style.top = `${y2}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
tileGhost(tileEl, rect) {
const ghost = el("div", "cg-ghost cg-tile-ghost");
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector(".tile-header");
if (header) {
const copy = header.cloneNode(true);
copy.removeAttribute("draggable");
ghost.append(copy);
}
ghost.append(el("div", "cg-tile-ghost-body"));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
setTileTarget(grab, target) {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove("tile--drop-target");
target?.el.classList.add("tile--drop-target");
}
grab.target = target;
}
cancelAllGrabs() {
for (const grab of this.grabs.values()) {
if (grab.kind === "tab") {
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
this.setTileTarget(grab, null);
} else if (grab.kind === "tile") {
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
} else if (grab.kind === "panel") {
grab.panel.el.style.pointerEvents = "";
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
@@ -4791,6 +4889,7 @@ var GestureBridge = class {
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
this.taps.clear();
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
}
onStatus(fps, hands) {
const { width, height } = this.canvas;
@@ -4863,6 +4962,13 @@ function injectStyles() {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+2 -1
View File
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
// logo rule gets the slot's plain dot).
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');

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