mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 11:19:42 +02:00
Compare commits
221
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eaa39a67e | ||
|
|
9faf567d41 | ||
|
|
91750d4bd5 | ||
|
|
63ac10dcc0 | ||
|
|
48e91fc1d9 | ||
|
|
3975089af3 | ||
|
|
9ad43fbd88 | ||
|
|
5e83cd3885 | ||
|
|
1172ef56f8 | ||
|
|
0996f99fbb | ||
|
|
b15033259a | ||
|
|
0feb5658ca | ||
|
|
82d588a339 | ||
|
|
1f41eacd87 | ||
|
|
a8b8530722 | ||
|
|
c3cee40f56 | ||
|
|
b7c18ab5b7 | ||
|
|
70e03b8ae7 | ||
|
|
de06cb12d1 | ||
|
|
eaa9044520 | ||
|
|
6c7d63d45e | ||
|
|
6bbfc44054 | ||
|
|
3d341f46a4 | ||
|
|
f0b762e745 | ||
|
|
771c23e261 | ||
|
|
0ff9b798af | ||
|
|
33c6cf5d20 | ||
|
|
9b9752d997 | ||
|
|
60fd71470c | ||
|
|
836402c2c4 | ||
|
|
f073742cf7 | ||
|
|
f3d7a86c16 | ||
|
|
34b867025d | ||
|
|
5d31e42db0 | ||
|
|
a6ce52d3e7 | ||
|
|
5ac27f3eec | ||
|
|
a38eab49cd | ||
|
|
9c56f7823d | ||
|
|
076f62fa1b | ||
|
|
90034d07d7 | ||
|
|
887ee7fe64 | ||
|
|
62227145dc | ||
|
|
2e65daff83 | ||
|
|
0e11012470 | ||
|
|
5b8313988f | ||
|
|
628b6e5832 | ||
|
|
aca358126d | ||
|
|
239f15437b | ||
|
|
f78b820302 | ||
|
|
d571c41fb1 | ||
|
|
f9f00bdb5e | ||
|
|
78ed6bf004 | ||
|
|
43568775e6 | ||
|
|
36f8afdaba | ||
|
|
1855d9423b | ||
|
|
7ba1e51c51 | ||
|
|
b4c87360d3 | ||
|
|
ee038ad83c | ||
|
|
f2d5af2b4c | ||
|
|
dedf56e24b | ||
|
|
71647e090d | ||
|
|
35e21dd676 | ||
|
|
3072449c94 | ||
|
|
bade1f1966 | ||
|
|
5205649889 | ||
|
|
e65b4f35e6 | ||
|
|
50a1b58d2b | ||
|
|
9e8a851a2c | ||
|
|
03a9259ae1 | ||
|
|
ba5649bcd4 | ||
|
|
c1fde8e639 | ||
|
|
1c19641280 | ||
|
|
79b9181932 | ||
|
|
0168444fda | ||
|
|
5a13073657 | ||
|
|
8f62faf130 | ||
|
|
7b4832b3e4 | ||
|
|
7dd78d9d71 | ||
|
|
06f1e5ddc8 | ||
|
|
4544379b3b | ||
|
|
fa23f8be2a | ||
|
|
bc940a3953 | ||
|
|
432faf7254 | ||
|
|
69aa5724e7 | ||
|
|
876dbca074 | ||
|
|
fe50ad0be3 | ||
|
|
ded7911d1e | ||
|
|
2797098aa5 | ||
|
|
4ecf7fa228 | ||
|
|
c041a418e0 | ||
|
|
59b92f2991 | ||
|
|
ef071ed485 | ||
|
|
683a1c218f | ||
|
|
435a56be10 | ||
|
|
5b8c73827e | ||
|
|
3c08971ff8 | ||
|
|
6bfc1b88d8 | ||
|
|
6bffc0481c | ||
|
|
f5e59283f9 | ||
|
|
8d92e577f8 | ||
|
|
ecb2e3d8f3 | ||
|
|
8e2594a274 | ||
|
|
3857fba065 | ||
|
|
28b41efa58 | ||
|
|
063e6fc076 | ||
|
|
136f11549b | ||
|
|
dd7774f9ad | ||
|
|
d6c87bbd27 | ||
|
|
3ea1e03aaf | ||
|
|
afab34608a | ||
|
|
eaf924a291 | ||
|
|
da7ffec734 | ||
|
|
1010d80b4f | ||
|
|
7d46a9d336 | ||
|
|
3e75e7509f | ||
|
|
b620e42ffa | ||
|
|
b33d6da58a | ||
|
|
d157911166 | ||
|
|
cde90a465b | ||
|
|
34c903da41 | ||
|
|
551a7f2792 | ||
|
|
331841f482 | ||
|
|
aa3730aace | ||
|
|
6234aae919 | ||
|
|
34439d18e5 | ||
|
|
0fced6ea97 | ||
|
|
79fd2dc8e4 | ||
|
|
0ae679553b | ||
|
|
2c55fe907e | ||
|
|
3d59e59e37 | ||
|
|
019118ba9d | ||
|
|
45a205b4e5 | ||
|
|
7a4d520cd0 | ||
|
|
08d7fa1985 | ||
|
|
0c6cbd0718 | ||
|
|
8b2cb49940 | ||
|
|
fb5c1bfca5 | ||
|
|
7b95856cc5 | ||
|
|
3a40dae1dd | ||
|
|
a81d344fb5 | ||
|
|
16980d644d | ||
|
|
b538429f6e | ||
|
|
7689a43782 | ||
|
|
950450b137 | ||
|
|
55cd59a346 | ||
|
|
209a539489 | ||
|
|
71e6cfa0a2 | ||
|
|
be9454a29b | ||
|
|
29c2701f83 | ||
|
|
f152d551d1 | ||
|
|
6844cccd18 | ||
|
|
ac8e0bdcbb | ||
|
|
9d6c010819 | ||
|
|
183efacc93 | ||
|
|
4a94eb9ba7 | ||
|
|
e589004926 | ||
|
|
5f373a9324 | ||
|
|
ac5f2aa3f4 | ||
|
|
2881ef5fa5 | ||
|
|
310a4c2489 | ||
|
|
29d8ca16b4 | ||
|
|
92e54163e0 | ||
|
|
39a91a0c7f | ||
|
|
cea199651a | ||
|
|
656ab98934 | ||
|
|
e6382ecfc0 | ||
|
|
4d6d187883 | ||
|
|
d3fc07ee28 | ||
|
|
b2de04c730 | ||
|
|
eada647d7b | ||
|
|
233d5f25c4 | ||
|
|
cf9022ac9a | ||
|
|
87f1c9ccd4 | ||
|
|
d8c23210bb | ||
|
|
7c3877016b | ||
|
|
7c4ad22f9c | ||
|
|
dd7a5b9275 | ||
|
|
217c90b6a1 | ||
|
|
93642fa616 | ||
|
|
6db526dbad | ||
|
|
e6deab98a6 | ||
|
|
5e0cea6ec8 | ||
|
|
2cc6ea1cfd | ||
|
|
c5ddb77adc | ||
|
|
d2d2ba3e4a | ||
|
|
33ad314bea | ||
|
|
3f4af2aa2e | ||
|
|
cf11a253d3 | ||
|
|
17824f1847 | ||
|
|
5fb56b0889 | ||
|
|
d7198dd058 | ||
|
|
53f61ca539 | ||
|
|
a4926e49f1 | ||
|
|
2eff6dcbdd | ||
|
|
b8ccfc33d1 | ||
|
|
3be03c6896 | ||
|
|
ba36cc36d4 | ||
|
|
7aecbd29df | ||
|
|
f12b5ac88b | ||
|
|
5206a044bb | ||
|
|
abc8598100 | ||
|
|
6ba311f38d | ||
|
|
decd263b17 | ||
|
|
82aeeaec02 | ||
|
|
ad57394618 | ||
|
|
fda1897109 | ||
|
|
eceaecf076 | ||
|
|
6f87186deb | ||
|
|
aa4217b8b1 | ||
|
|
c75d62ce04 | ||
|
|
c2e55fc210 | ||
|
|
24e51a3b8e | ||
|
|
0481db569d | ||
|
|
6d862d5323 | ||
|
|
57d5c7b1c2 | ||
|
|
9c63c41283 | ||
|
|
26487f0ec5 | ||
|
|
8da4a07a60 | ||
|
|
91bdaee724 | ||
|
|
56978f2cc9 | ||
|
|
fe77b5238e |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Switching to a fullscreen Claude tab is about ten times faster. Claude keeps that conversation itself and tmux holds no scrollback for it, so instead of downloading and replaying a 1 MB tail of old screen redraws on every switch, the browser loads just the current screen (a few KB), the same as a page load already did. Tabs whose pane does keep scrollback are unchanged.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+3
-1
@@ -107,7 +107,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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||
@@ -733,23 +733,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 +802,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 +944,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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,7 @@ 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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
After Width: | Height: | Size: 5.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
@@ -224,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
|
||||
@@ -259,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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||
// in place → fires the button's real click handler. Drift too far first and
|
||||
// it's treated as a stray move, not a tap.
|
||||
// • Tile "grab-to-move": with the tile grid open, pinch anywhere on a tile
|
||||
// and carry it onto another tile (the two trade places) or an empty cell
|
||||
// (it moves there). With Detach Tiles on (App Settings, per device, off by
|
||||
// default) a tile let go OUTSIDE the grid opens as a window of its own.
|
||||
// A session TAB carried onto a tile or an empty cell joins the grid there,
|
||||
// as a mouse-dragged tab does. Every move goes through app.js's own paths
|
||||
// (tile-grid.js tileDropTargetAt / dropOnTileTarget / detachTileAtPoint), so
|
||||
// the hand and the mouse can never disagree about what a drop does.
|
||||
//
|
||||
// Why in-page floats, not OS-window detach (decided 2026-06-08, see
|
||||
// docs/MULTIMONITOR_DESIGN.md): the hand only exists in the one page that owns
|
||||
@@ -52,11 +60,22 @@ declare global {
|
||||
saveSubagentWindowStates?: () => void;
|
||||
subagentWindowZIndex?: number;
|
||||
ultracodeWindowZIndex?: number;
|
||||
// The tile grid (tile-grid.js). All optional: an older dashboard without
|
||||
// them simply has no tile verbs for the hand.
|
||||
tileAtPoint?: (x: number, y: number) => string | null;
|
||||
canGrabTile?: (id: string) => boolean;
|
||||
tileDropTargetAt?: (x: number, y: number, draggedId: string) => TileTarget | null;
|
||||
dropOnTileTarget?: (draggedId: string, target: TileTarget) => boolean;
|
||||
isOverTileGrid?: (x: number, y: number) => boolean;
|
||||
tileDetachEnabled?: () => boolean;
|
||||
detachTileAtPoint?: (id: string, x: number, y: number, at?: { offsetX: number; offsetY: number }) => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_SELECTOR = '.session-tab';
|
||||
/** A tile of the open tile grid (tile-grid.js), keyed by `data-session-id`. */
|
||||
const TILE_SELECTOR = '#tileGrid .tile';
|
||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||
const PANEL_SELECTOR = '.cg-float';
|
||||
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||
@@ -81,11 +100,20 @@ const FLOAT_H = 420;
|
||||
const DETACH_PULL_PX = 70;
|
||||
/** If a button-pinch drifts more than this, it's a stray move, not a tap. */
|
||||
const TAP_CANCEL_PX = 45;
|
||||
/** How far (px) a grabbed tile must travel before it targets anything: a pinch
|
||||
* that barely moves is a stray one, and the tile stays where it is. */
|
||||
const TILE_MOVE_PX = 40;
|
||||
/** The width (px) of the small copy of a grabbed tile that follows the hand. */
|
||||
const TILE_GHOST_W = 280;
|
||||
|
||||
/** First letter colours: cyan left, violet right; green while pinching. */
|
||||
const handColor = (handedness: string, pinching: boolean): string =>
|
||||
pinching ? '#4ade80' : handedness === 'Right' ? '#a78bfa' : '#38bdf8';
|
||||
|
||||
/** Where a session carried by the hand would land in the tile grid: another
|
||||
* tile, or an empty cell (tile-grid.js tileDropTargetAt). */
|
||||
type TileTarget = { kind: 'tile'; id: string; el: HTMLElement } | { kind: 'cell'; cell: number; el: HTMLElement };
|
||||
|
||||
/** A floating in-page session panel (an iframe of `/session/:id`) the hand can
|
||||
* place and re-grab. Stays in this page's DOM, so it never leaves hand reach. */
|
||||
interface FloatingPanel {
|
||||
@@ -107,6 +135,29 @@ type Grab =
|
||||
oy: number;
|
||||
/** Pulled past the float-out threshold at least once. */
|
||||
armed: boolean;
|
||||
/** With the tile grid open: the tile or empty cell it would join (highlighted). */
|
||||
target: TileTarget | null;
|
||||
}
|
||||
| {
|
||||
/** A tile of the open grid, carried to another cell or (Detach Tiles)
|
||||
* out of the grid. The tile itself never moves until the drop: it dims
|
||||
* (`tile--dragging`, the mouse drag's own class) and a small copy of
|
||||
* it follows the hand. */
|
||||
kind: 'tile';
|
||||
id: string;
|
||||
el: HTMLElement;
|
||||
ghost: HTMLElement;
|
||||
/** Where in the tile the hand closed, so a pop-out window lands under it. */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
/** Travelled past TILE_MOVE_PX at least once. */
|
||||
armed: boolean;
|
||||
/** The tile or empty cell it would drop onto (highlighted). */
|
||||
target: TileTarget | null;
|
||||
/** Outside the grid with Detach Tiles on: letting go opens a window. */
|
||||
out: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'panel';
|
||||
@@ -224,7 +275,7 @@ class GestureBridge {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add('on');
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
this.status.textContent = 'on: pinch a tab, tile, window, or button';
|
||||
} catch (err) {
|
||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||
@@ -295,6 +346,35 @@ class GestureBridge {
|
||||
return;
|
||||
}
|
||||
|
||||
// A tile of the open tile grid → carry it to another cell, or (Detach
|
||||
// Tiles) out of the grid. Anywhere on the tile: the hand is choosing the
|
||||
// whole tile, as with the agent windows. A tile that can do neither (alone
|
||||
// or zoomed, Detach Tiles off) is left alone, so the pinch falls through.
|
||||
const tileId = window.app?.tileAtPoint?.(x, y) ?? null;
|
||||
const tileEl = tileId ? this.hitClosest(x, y, TILE_SELECTOR) : null;
|
||||
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
|
||||
const rect = tileEl.getBoundingClientRect();
|
||||
const ghost = this.tileGhost(tileEl, rect);
|
||||
document.body.append(ghost);
|
||||
tileEl.classList.add('tile--dragging');
|
||||
this.grabs.set(hand, {
|
||||
kind: 'tile',
|
||||
id: tileId,
|
||||
el: tileEl,
|
||||
ghost,
|
||||
offsetX: Math.max(0, Math.min(x - rect.left, rect.width)),
|
||||
offsetY: Math.max(0, Math.min(y - rect.top, rect.height)),
|
||||
ox: x,
|
||||
oy: y,
|
||||
armed: false,
|
||||
target: null,
|
||||
out: false,
|
||||
});
|
||||
this.positionGhost(ghost, x, y);
|
||||
this.status.textContent = 'moving tile';
|
||||
return;
|
||||
}
|
||||
|
||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
@@ -308,7 +388,7 @@ class GestureBridge {
|
||||
document.body.append(ghost);
|
||||
|
||||
tab.classList.add('cg-grabbed');
|
||||
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
|
||||
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false, target: null });
|
||||
this.positionGhost(ghost, x, y);
|
||||
return;
|
||||
}
|
||||
@@ -325,13 +405,42 @@ class GestureBridge {
|
||||
|
||||
private onDrag(hand: string, x: number, y: number): void {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === 'tile') {
|
||||
this.positionGhost(grab.ghost, x, y);
|
||||
if (!grab.armed && Math.hypot(x - grab.ox, y - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
|
||||
if (!grab.armed) return;
|
||||
const app = window.app;
|
||||
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
|
||||
this.setTileTarget(grab, target);
|
||||
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x, y);
|
||||
grab.out = out;
|
||||
grab.ghost.classList.toggle('cg-armed', !!target);
|
||||
grab.ghost.classList.toggle('cg-out', out);
|
||||
this.status.textContent = target
|
||||
? target.kind === 'tile'
|
||||
? 'release to swap tiles'
|
||||
: 'release to move the tile here'
|
||||
: out
|
||||
? 'release to open in a new window'
|
||||
: 'moving tile';
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'tab') {
|
||||
this.positionGhost(grab.ghost, x, y);
|
||||
const pulled = Math.hypot(x - grab.ox, y - grab.oy) >= DETACH_PULL_PX;
|
||||
if (pulled !== grab.armed) {
|
||||
// The tile grid open: a tile or an empty cell under the hand takes the
|
||||
// tab there, as a mouse-dragged tab does.
|
||||
const target = pulled ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
|
||||
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
|
||||
this.setTileTarget(grab, target);
|
||||
if (pulled !== grab.armed || targetChanged) {
|
||||
grab.armed = pulled;
|
||||
grab.ghost.classList.toggle('cg-armed', pulled);
|
||||
this.status.textContent = pulled ? 'release to float out' : 'on — pinch a tab';
|
||||
this.status.textContent = target
|
||||
? 'release to tile it here'
|
||||
: pulled
|
||||
? 'release to float out'
|
||||
: 'on — pinch a tab';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -354,17 +463,44 @@ class GestureBridge {
|
||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
this.status.textContent = 'on: pinch a tab, tile, window, or button';
|
||||
}
|
||||
}
|
||||
|
||||
private onDrop(hand: string, x: number, y: number): void {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === 'tile') {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove('tile--dragging');
|
||||
this.setTileTarget(grab, null);
|
||||
const app = window.app;
|
||||
if (!grab.armed) {
|
||||
this.flash('cancelled');
|
||||
return;
|
||||
}
|
||||
// Asked again at the drop: the grid may have changed since the last frame.
|
||||
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
|
||||
if (target) {
|
||||
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? 'moved tile' : 'cancelled');
|
||||
} else if (grab.out && !app?.isOverTileGrid?.(x, y)) {
|
||||
// A window opened with no click behind it is what popup blockers stop:
|
||||
// detachSession then says so in a toast and the tile stays.
|
||||
const opened = app?.detachTileAtPoint?.(grab.id, x, y, { offsetX: grab.offsetX, offsetY: grab.offsetY });
|
||||
this.flash(opened ? 'opened in a new window' : 'pop-out blocked: allow popups for this site');
|
||||
} else {
|
||||
this.flash('cancelled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'tab') {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
if (grab.armed) this.floatPanel(grab.id, x, y);
|
||||
this.setTileTarget(grab, null);
|
||||
const target = grab.armed ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
|
||||
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? 'tiled' : 'cancelled');
|
||||
else if (grab.armed) this.floatPanel(grab.id, x, y);
|
||||
else this.flash('cancelled');
|
||||
return;
|
||||
}
|
||||
@@ -505,6 +641,33 @@ class GestureBridge {
|
||||
ghost.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
/** A small copy of a tile to follow the hand: its header (a copy: no
|
||||
* listeners come along) over an empty body, in the tile's proportions. */
|
||||
private tileGhost(tileEl: HTMLElement, rect: DOMRect): HTMLElement {
|
||||
const ghost = el('div', 'cg-ghost cg-tile-ghost');
|
||||
const width = Math.min(TILE_GHOST_W, rect.width);
|
||||
ghost.style.width = `${width}px`;
|
||||
ghost.style.height = `${Math.max(48, Math.round((width * rect.height) / Math.max(1, rect.width)))}px`;
|
||||
const header = tileEl.querySelector('.tile-header');
|
||||
if (header) {
|
||||
const copy = header.cloneNode(true) as HTMLElement;
|
||||
copy.removeAttribute('draggable');
|
||||
ghost.append(copy);
|
||||
}
|
||||
ghost.append(el('div', 'cg-tile-ghost-body'));
|
||||
return ghost;
|
||||
}
|
||||
|
||||
/** Highlights the tile or empty cell a carried session would drop onto
|
||||
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
|
||||
private setTileTarget(grab: { target: TileTarget | null }, target: TileTarget | null): void {
|
||||
if (grab.target?.el !== target?.el) {
|
||||
grab.target?.el.classList.remove('tile--drop-target');
|
||||
target?.el.classList.add('tile--drop-target');
|
||||
}
|
||||
grab.target = target;
|
||||
}
|
||||
|
||||
private cancelAllGrabs(): void {
|
||||
// Floats themselves persist (they're placed windows) — only release any
|
||||
// in-progress grab cleanly, restoring a moved panel's interactivity.
|
||||
@@ -512,6 +675,11 @@ class GestureBridge {
|
||||
if (grab.kind === 'tab') {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === 'tile') {
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove('tile--dragging');
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === 'panel') {
|
||||
grab.panel.el.style.pointerEvents = '';
|
||||
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
||||
@@ -525,6 +693,7 @@ class GestureBridge {
|
||||
document
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t) => t.classList.remove('tile--dragging'));
|
||||
}
|
||||
|
||||
private onStatus(fps: number, hands: HandState[]): void {
|
||||
@@ -607,6 +776,13 @@ function injectStyles(): void {
|
||||
outline: 2px solid #38bdf8; outline-offset: -2px;
|
||||
}
|
||||
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
|
||||
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
|
||||
.cg-tile-ghost {
|
||||
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
|
||||
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
|
||||
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
|
||||
.cg-dock {
|
||||
position: fixed; right: 12px; bottom: 156px; z-index: ${Z + 3};
|
||||
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
|
||||
|
||||
@@ -1,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.
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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(' ') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
+661
-54
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
+89
-5
@@ -25,6 +25,7 @@
|
||||
'.response-viewer-content',
|
||||
'.file-preview-content',
|
||||
'.session-tab-name',
|
||||
'.tab-name',
|
||||
'.session-name',
|
||||
'.case-name',
|
||||
'.notif-item-message',
|
||||
@@ -61,6 +62,8 @@
|
||||
'Collapse session sidebar': '收起会话侧边栏',
|
||||
'Expand session sidebar': '展开会话侧边栏',
|
||||
'Filter sessions': '筛选会话',
|
||||
'Search sessions': '搜索会话',
|
||||
'No sessions match': '没有匹配的会话',
|
||||
'Admin Panel': '管理面板',
|
||||
'Open admin panel': '打开管理面板',
|
||||
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
||||
@@ -90,6 +93,16 @@
|
||||
'Move down': '下移',
|
||||
'Move to Ungrouped': '移到未分组',
|
||||
'Move to new group': '移到新分组',
|
||||
'New session': '新建会话',
|
||||
'A session is already starting.': '已有会话正在启动。',
|
||||
'Follow parent again': '重新跟随父会话',
|
||||
'Parent closed; placed on its own': '父会话已关闭;单独放置',
|
||||
'Parent loop; placed on its own': '父会话形成循环;单独放置',
|
||||
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
|
||||
Focus: '焦点',
|
||||
'Add to Focus': '添加到焦点',
|
||||
'Remove from Focus': '从焦点移除',
|
||||
'Focus actions': '焦点操作',
|
||||
'Could not save tab groups.': '无法保存标签分组。',
|
||||
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||
@@ -136,6 +149,8 @@
|
||||
Drag: '拖动',
|
||||
"a tile's header": '窗格的标题栏',
|
||||
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||
"a tile's header out of the window": '窗格的标题栏到窗口之外',
|
||||
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
|
||||
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||
'Remove Focused Tile': '移除聚焦的窗格',
|
||||
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||
@@ -153,8 +168,9 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
|
||||
// view's drop (image-input.js) uses 'Only image or video files are supported'.
|
||||
'Unsupported file type': '不支持的文件类型',
|
||||
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
|
||||
'No active session': '没有活动会话',
|
||||
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
|
||||
@@ -163,6 +179,11 @@
|
||||
'Failed to restore terminal size': '恢复终端尺寸失败',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
|
||||
// pop-out window's title as a handle back onto the tiles.
|
||||
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
|
||||
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
|
||||
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
'Resize tile rows': '调整窗格行高',
|
||||
Attach: '附加',
|
||||
@@ -211,6 +232,9 @@
|
||||
'Close window': '关闭窗口',
|
||||
'Session unavailable': '会话不可用',
|
||||
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
|
||||
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
|
||||
'Session moved': '会话已移走',
|
||||
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
|
||||
|
||||
// Welcome / quick start / common actions
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
@@ -340,6 +364,36 @@
|
||||
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
|
||||
English: 'English',
|
||||
Appearance: '外观',
|
||||
// App Settings > Animations (#571). 平铺 is the grid, 窗格 one tile in it.
|
||||
Animations: '动画',
|
||||
'How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.':
|
||||
'标签页、终端面板、智能体窗口和平铺窗格如何出现。默认全部关闭,选择后立即生效。',
|
||||
Entrances: '入场',
|
||||
'Entrance Theme': '入场主题',
|
||||
'One look for how new tabs, terminal panes, agent windows and their lines arrive.':
|
||||
'为新标签页、终端面板、智能体窗口及其连线的出现方式选择统一的风格。',
|
||||
'Off (default)': '关闭(默认)',
|
||||
'Terminal (CRT)': '终端(CRT)',
|
||||
'Beam down': '光束降临',
|
||||
'Launch (tiles fly from tabs)': '发射(窗格从标签页飞出)',
|
||||
'Soft focus (blur)': '柔焦(模糊)',
|
||||
Quiet: '安静',
|
||||
Playful: '活泼',
|
||||
'Custom (set in the lab)': '自定义(在实验室中设置)',
|
||||
'Tile Animations': '平铺动画',
|
||||
'How tiles arrive when the grid opens and leave when it closes. A theme above presets it.':
|
||||
'平铺打开时窗格如何出现、关闭时如何离开。上方的主题会预设此项。',
|
||||
'Fly from tab': '从标签页飞出',
|
||||
Deal: '发牌',
|
||||
Cascade: '级联',
|
||||
Pop: '弹出',
|
||||
Soft: '柔和',
|
||||
'None (tiles just appear)': '无(窗格直接出现)',
|
||||
Lab: '实验室',
|
||||
'Animation Lab': '动画实验室',
|
||||
'Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.':
|
||||
'关闭设置,并按界面并排打开所有样式,可重放和调速。等同于在网址后添加 ?animlab=1。',
|
||||
'Open lab': '打开实验室',
|
||||
Skin: '皮肤',
|
||||
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
|
||||
'Daylight Blue': '日光蓝',
|
||||
@@ -369,6 +423,12 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
'Detach Tiles': '分离窗格',
|
||||
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
|
||||
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
@@ -425,6 +485,9 @@
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
|
||||
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
|
||||
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
@@ -592,6 +655,11 @@
|
||||
'Audio Alerts': '声音提醒',
|
||||
'Push Notifications': '推送通知',
|
||||
'Notification Levels': '通知级别',
|
||||
'Toast display time': '弹出提示显示时长',
|
||||
'How long the corner pop-ups stay on screen.': '角落弹出提示在屏幕上停留的时长。',
|
||||
'Browser notification display time': '浏览器通知显示时长',
|
||||
'How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.':
|
||||
'桌面通知在 Codeman 关闭它之前保持显示的时长。系统可能会更早关闭它。',
|
||||
Critical: '严重',
|
||||
'Per-Event Settings': '按事件设置',
|
||||
'Permission prompts': '权限提示',
|
||||
@@ -622,6 +690,8 @@
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Open a session to attach a file': '请先打开一个会话再添加附件',
|
||||
'Only image or video files are supported': '仅支持图片或视频文件',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
@@ -658,6 +728,16 @@
|
||||
'Session Color': '会话颜色',
|
||||
'Working Directory': '工作目录',
|
||||
'Set working directory': '设置工作目录',
|
||||
// Session Options > Session > Working folder (session-ui.js openSessionOptions).
|
||||
'Working folder': '工作目录',
|
||||
"Where this session's CLI runs, on the machine hosting Codeman.":
|
||||
'此会话的 CLI 运行所在的目录,位于运行{name}的机器上。',
|
||||
"Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine.":
|
||||
'此会话的 CLI 运行所在的目录,位于通过 SSH 连接的远程主机上,而不是本机。',
|
||||
"On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in.":
|
||||
'位于运行{name}的机器上,并挂载到此会话 CLI 运行所在的 Docker 容器中。',
|
||||
'Path copied': '路径已复制',
|
||||
'Copy failed': '复制失败',
|
||||
'Resume Conversation': '继续对话',
|
||||
'Close Session': '关闭会话',
|
||||
'Choose how to close': '选择关闭方式',
|
||||
@@ -718,6 +798,8 @@
|
||||
|
||||
// Dynamic common status / toasts
|
||||
'Settings saved': '设置已保存',
|
||||
'Settings applied': '设置已应用',
|
||||
'Save and keep Settings open': '保存并保持设置打开',
|
||||
'Settings saved locally': '设置已保存到本机',
|
||||
'Tunnel active': '隧道已启用',
|
||||
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
||||
@@ -1172,6 +1254,9 @@
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
[/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`],
|
||||
[/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`],
|
||||
[
|
||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||
@@ -1237,9 +1322,8 @@
|
||||
// The same while tiles can move, with the drag hint on a second line.
|
||||
// Anchored on the hint, so a bare state word is safe here.
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||
(_m, state, duration) =>
|
||||
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
|
||||
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
|
||||
+188
-16
@@ -14,6 +14,69 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
|
||||
// sits invisibly over the button, and the browser opens the picker itself.
|
||||
//
|
||||
// Nothing here may open the picker from script. While the keyboard is up,
|
||||
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
|
||||
// re-dispatches it as a scripted click, and iOS will not open a file picker
|
||||
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
|
||||
// milliseconds after pointerdown, no picker, no error, and no real click after
|
||||
// it either because the gesture was cancelled. A native tap on the input is not
|
||||
// a button tap, so the shim leaves it alone and the click stays trusted. It also
|
||||
// anchors the picker popover to the input's own frame (a `hidden` input has
|
||||
// none, which is why the composer's picker floated loose).
|
||||
//
|
||||
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
|
||||
// so the fixed toolbar never reflows out from under the finger mid-tap.
|
||||
//
|
||||
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
|
||||
// responder to present the picker, and on DEVICE it does not hand the keyboard
|
||||
// back when the picker closes, even though focus never left the textarea.
|
||||
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
|
||||
// not take a keyboard measurement from one.) A blur-then-focus on the close
|
||||
// of the picker, cancel and change alike, restores DOM focus and still leaves
|
||||
// the keyboard down, because iOS only raises it inside a user gesture. So the
|
||||
// user taps the prompt when ready to type, which is the keyboard policy
|
||||
// everywhere else in the app.
|
||||
//
|
||||
// Do not try to hide the keyboard's exit by freezing the page's keyboard
|
||||
// state across the picker. That was tried (holdKeyboardState, reverted) to
|
||||
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
|
||||
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
|
||||
// bar strand in the MIDDLE of the screen over a dead black band, which is far
|
||||
// worse than the reflow. The page must follow the keyboard out.
|
||||
const attachBtn = document.getElementById('attachBtnMobile');
|
||||
const attachInput = document.getElementById('attachFileInput');
|
||||
if (attachBtn && attachInput) {
|
||||
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
// The button is the ACCESSIBLE control (the input is aria-hidden and not
|
||||
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
|
||||
// the input. That click is trusted, so opening the picker from it is allowed.
|
||||
attachBtn.addEventListener('click', () => attachInput.click());
|
||||
attachInput.addEventListener('click', (event) => {
|
||||
if (!this.activeSessionId) {
|
||||
event.preventDefault(); // refuse before the picker opens, not after a pick
|
||||
this.showToast('Open a session to attach a file', 'error');
|
||||
return;
|
||||
}
|
||||
// Captured before the picker opens: the session can be deleted while it is
|
||||
// up, and the pick must not land on whatever replaced it.
|
||||
this._attachSessionId = this.activeSessionId;
|
||||
});
|
||||
attachInput.addEventListener('change', () => {
|
||||
const files = Array.from(attachInput.files || []);
|
||||
// Reset so picking the same file again still fires change.
|
||||
attachInput.value = '';
|
||||
// Captured when the picker opened (see the click handler above).
|
||||
const sessionId = this._attachSessionId;
|
||||
this._attachSessionId = null;
|
||||
if (files.length > 0) {
|
||||
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
@@ -38,15 +101,100 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
|
||||
if (mediaFiles.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
/** Upload the toolbar Attach selection and put the paths on the prompt. The
|
||||
* Compose dialog is deliberately not involved (see initImageInput).
|
||||
*
|
||||
* With local echo on (every phone by default) the paths go into the OVERLAY as
|
||||
* pending text rather than to the PTY. That is what makes them part of "what the
|
||||
* user typed": they paint at the prompt, anything typed after them appends in
|
||||
* order, Enter flushes the whole line, and opening Compose adopts path and prose
|
||||
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
|
||||
* instead would strand the path on the terminal while Compose took only the prose.
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
async _attachFromToolbar(files, sessionId) {
|
||||
const media = files.filter((f) => this._promptAttachKind(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
// Deleted while the upload ran: a write would go to a dead session, and the
|
||||
// flushed maps below would gain an entry whose cleanup has already run.
|
||||
if (this.sessions && !this.sessions.has(sessionId)) return;
|
||||
const text = paths.join(' ');
|
||||
|
||||
// The prompt composer, if it opened for this session while the upload ran, is
|
||||
// where the prompt now lives: it took the terminal's pending text with it, so
|
||||
// appending behind it would strand the path outside the text being composed.
|
||||
// _insertComposerText adds its own separator and fires the draft save.
|
||||
const openComposer = KeyboardAccessoryBar._composerOverlay;
|
||||
const composerTextarea =
|
||||
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
|
||||
? openComposer.querySelector('.prompt-composer-textarea')
|
||||
: null;
|
||||
if (composerTextarea) {
|
||||
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
// The overlay holds the prompt only while local echo actually owns it. A
|
||||
// session handed back to plain PTY echo by a composer nav key (echo
|
||||
// passthrough) skips the whole overlay branch in onData, Enter included, so
|
||||
// text buffered there would sit on screen and never be submitted. Nor while
|
||||
// the tile grid owns the terminal area: the main terminal is parked and
|
||||
// hidden then, and a tile's Enter never reaches its overlay, so the path
|
||||
// goes to the PTY like any other background write.
|
||||
const overlayOwnsPrompt =
|
||||
stillActive &&
|
||||
!this._tilesOwnTerminal?.() &&
|
||||
this._localEchoEnabled &&
|
||||
this._localEchoOverlay &&
|
||||
!this._echoPassthroughSessions?.has(sessionId);
|
||||
if (overlayOwnsPrompt) {
|
||||
// Prompt text the overlay has not adopted yet (a tab completion, a restored
|
||||
// tab) only lands in its flushed half once detection runs, and appendText
|
||||
// runs it AFTER we would have measured. Detect first, or the separator is
|
||||
// computed against an empty string and the path fuses onto a real word.
|
||||
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
|
||||
this._localEchoOverlay.detectBufferText();
|
||||
}
|
||||
const flushed = this._localEchoOverlay.getFlushed().text || '';
|
||||
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
|
||||
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
|
||||
this._localEchoOverlay.appendText(gap + text + ' ');
|
||||
} else {
|
||||
// No buffer to measure, so lead with a separator unconditionally rather than
|
||||
// risk fusing onto whatever the CLI's own composer already holds.
|
||||
const out = ` ${text} `;
|
||||
this._sendInputAsync(sessionId, out);
|
||||
// A background session's prompt is tracked in the flushed maps, which is what
|
||||
// a later composer open adopts and erases. An untracked write leaves that
|
||||
// count short, so the erase would eat the tail of this path instead. Not for
|
||||
// a passthrough session: its cursor can sit mid-text, nothing tracks its
|
||||
// composer, and Enter there never clears the maps, so a record would offset
|
||||
// the overlay for the next prompt instead.
|
||||
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
|
||||
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
@@ -82,15 +230,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
// Check for images or videos in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +266,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
/** What a pick or a paste may upload: 'image', or 'video' for the camera
|
||||
* roll's clips (the server streams those to disk under their own cap), else
|
||||
* null. The one filter behind Attach, drop, the Compose and paste-dialog
|
||||
* pickers and the clipboard handlers. The MIME type decides; some Android
|
||||
* file managers hand over a File with none, so the name's extension is the
|
||||
* fallback, mirroring the server's own allowlists. */
|
||||
_promptAttachKind(file) {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
|
||||
return null;
|
||||
},
|
||||
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
@@ -136,6 +297,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return [];
|
||||
@@ -150,9 +312,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
@@ -173,6 +336,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
if (!failReason && err && err.message) failReason = err.message;
|
||||
console.warn('Image upload failed:', err);
|
||||
results[i] = null;
|
||||
} finally {
|
||||
@@ -195,11 +359,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
@@ -207,7 +371,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
// A video takes the server's disk-streamed path and its larger cap; the flag
|
||||
// has to ride the URL because the size budget is fixed before the part is read.
|
||||
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
@@ -229,9 +396,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
// Only an image goes through the canvas; a video is uploaded as picked. With no
|
||||
// MIME (some Android file managers) the name's extension stands in, as it does in
|
||||
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
|
||||
const name = (file.name || '').toLowerCase();
|
||||
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
|
||||
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
+184
-27
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -394,6 +394,19 @@
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
|
||||
<!-- Session-name search (app.js setTabRailSearch): a view filter over the
|
||||
rows below, in memory only. Shown with the rail, never elsewhere. -->
|
||||
<div class="session-sidebar-filter tab-rail-search">
|
||||
<input type="search" id="tabRailSearch" class="session-sidebar-filter-input"
|
||||
placeholder="Search sessions" aria-label="Search sessions"
|
||||
autocomplete="off" spellcheck="false"
|
||||
oninput="app.setTabRailSearch(this.value)"
|
||||
onkeydown="app.handleTabRailSearchKeydown(event)">
|
||||
<button type="button" id="tabRailSearchClear" class="tab-rail-search-clear"
|
||||
aria-label="Clear search" title="Clear search"
|
||||
onclick="app.clearTabRailSearch()" hidden>×</button>
|
||||
</div>
|
||||
<div id="tabRailSearchEmpty" class="tab-rail-search-empty" role="status" hidden>No sessions match</div>
|
||||
<div
|
||||
id="tabRailResizeHandle"
|
||||
class="tab-rail-resize-handle"
|
||||
@@ -601,7 +614,11 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>‹</button>
|
||||
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>›</button>
|
||||
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
@@ -621,6 +638,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
|
||||
<div class="folder-grid-overlay" id="folderGridOverlay">
|
||||
<div class="folder-grid-window">
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
|
||||
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
|
||||
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||
<div class="response-viewer" id="responseViewer">
|
||||
<div class="response-viewer-header">
|
||||
@@ -744,6 +777,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
<!-- Phone-only Attach (wired in image-input.js): the button opens its own file input,
|
||||
which sits invisibly over the button so iOS anchors the picker popover to it. The
|
||||
Compose dialog is deliberately not involved: it focuses its textarea, which raises
|
||||
the keyboard under the picker sheet. Sits BEFORE the mic so the two share order: 5
|
||||
and source order keeps Attach on the left. -->
|
||||
<span class="btn-attach-mobile-wrap">
|
||||
<button type="button" class="btn-attach-mobile" id="attachBtnMobile" title="Attach" aria-label="Attach">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="m21 15-5-5L5 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
|
||||
</span>
|
||||
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
|
||||
title="Voice input" aria-label="Start voice input" aria-pressed="false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -847,6 +895,7 @@
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
|
||||
<div><kbd>Drag</kbd> a tile's header out of the window</div><div>Open the Tile in Its Own Window (Detach Tiles)</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||
@@ -1273,6 +1322,22 @@
|
||||
<!-- Hidden wholesale for non-claude sessions and for a session with
|
||||
no live process (session-ui.js: openSessionOptions). -->
|
||||
<div id="sessionRespawnSection">
|
||||
<!-- Claude Code (v2.1.251+) reports the main conversation's prompt-cache
|
||||
state on the statusline; the exporter keeps it per session and this
|
||||
row shows it live (session-ui.js: updatePromptCacheStatus). -->
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Prompt cache</h4></div>
|
||||
<p class="set-group-hint">While the cached conversation is warm, a turn re-reads it at the cheap cached rate. Once it sits idle past the cache lifetime, the next turn re-processes the whole context at the cache-write rate.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Loop control</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -1394,6 +1459,16 @@
|
||||
<input type="text" id="modalSessionName" class="set-input" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row set-row-block">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Working folder</span>
|
||||
<span class="set-row-desc" id="modalSessionPathDesc">Where this session's CLI runs, on the machine hosting Codeman.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<code id="modalSessionPath" class="set-static mono" style="flex: 1; min-width: 0; word-break: break-all; user-select: all;"></code>
|
||||
<button type="button" class="btn-toolbar btn-sm" id="modalSessionPathCopy" onclick="app.copySessionPath()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row set-row-block">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Session color</span>
|
||||
@@ -1635,6 +1710,7 @@
|
||||
Save; row-reverse keeps Save to the left of it on phones. -->
|
||||
<div class="set-head-actions">
|
||||
<button class="modal-close" onclick="app.closeAppSettings()" aria-label="Close app settings">×</button>
|
||||
<button class="set-head-save set-head-apply" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
|
||||
<button class="set-head-save" onclick="app.saveAppSettings()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1663,6 +1739,10 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
|
||||
<span>Appearance</span>
|
||||
</button>
|
||||
<button type="button" class="set-rail-item" data-section="settings-animations">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
|
||||
<span>Animations</span>
|
||||
</button>
|
||||
<button type="button" class="set-rail-item" data-section="settings-models">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
|
||||
<span>Models</span>
|
||||
@@ -2053,7 +2133,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
|
||||
<h2>Appearance</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
|
||||
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
|
||||
@@ -2077,21 +2157,6 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Entrance Animations</span>
|
||||
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="set-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2216,6 +2281,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CLI Logos on Tabs</span>
|
||||
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out Button on Tabs</span>
|
||||
@@ -2223,6 +2295,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
|
||||
<span class="set-row-desc">Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTileDetach"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
@@ -2237,6 +2316,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsSpawnedTabsFollowParentItem" data-search="spawned child session follow parent tab group lineage move">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawned Tabs Follow Their Parent <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSpawnedTabsFollowParent"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
@@ -2248,6 +2334,70 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Animations ═══════════════════════════════════════════════
|
||||
Every entrance animation in one place (entrance-animations.js).
|
||||
All per-device localStorage keys, applied as they are picked,
|
||||
never part of PUT /api/settings. -->
|
||||
<section class="set-section" id="settings-animations" data-label="Animations">
|
||||
<div class="set-section-head">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
|
||||
<h2>Animations</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Entrance Theme</span>
|
||||
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="set-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="launch">Launch (tiles fly from tabs)</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tile Animations</span>
|
||||
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
|
||||
</div>
|
||||
<select id="appSettingsTileAnim" class="set-select">
|
||||
<option value="settle">Off (default)</option>
|
||||
<option value="fly">Fly from tab</option>
|
||||
<option value="deal">Deal</option>
|
||||
<option value="crt">CRT</option>
|
||||
<option value="beam">Beam down</option>
|
||||
<option value="cascade">Cascade</option>
|
||||
<option value="pop">Pop</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="off">None (tiles just appear)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Lab</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Animation Lab</span>
|
||||
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
|
||||
</div>
|
||||
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Models ═══════════════════════════════════════════════════ -->
|
||||
<section class="set-section" id="settings-models" data-label="Models">
|
||||
<div class="set-section-head">
|
||||
@@ -2523,6 +2673,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="agentSkillGroup">
|
||||
<div class="set-group-head"><h4>All agent CLIs</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2559,13 +2722,6 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="agent skill codeman api workers">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Let new sessions start workers, send prompts and wait for results over the API.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Workspace Hooks</span>
|
||||
@@ -2644,17 +2800,17 @@
|
||||
<div class="set-group" id="mcpSyncGroup">
|
||||
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
|
||||
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity copilot github">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable MCP server sync</span>
|
||||
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
|
||||
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs, and GitHub Copilot CLI if it is installed, by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Sync MCP servers across CLIs</span>
|
||||
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
|
||||
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers (and GitHub Copilot CLI's) into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>, <code>COPILOT_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
|
||||
@@ -3057,6 +3213,7 @@
|
||||
</div>
|
||||
<div class="form-actions set-foot">
|
||||
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
|
||||
<button class="btn-toolbar" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveAppSettings()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -636,12 +636,13 @@ function applyOneShotCtrl(data) {
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
|
||||
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
|
||||
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
|
||||
// an ACK, which would wedge the durable input queue. So the prompt budget is
|
||||
// that limit minus the two markers, derived here once so the refusal in
|
||||
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
|
||||
// The composer's Send, and the shell Paste dialog's, goes out as ONE
|
||||
// bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a
|
||||
// frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB,
|
||||
// compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable
|
||||
// input queue. So the budget is that limit minus the two markers, derived here
|
||||
// once so the refusal in _sendBracketedPaste() and the toast that names the
|
||||
// maximum cannot drift.
|
||||
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
@@ -664,7 +665,7 @@ const KeyboardAccessoryBar = {
|
||||
_composerDrafts: new Map(),
|
||||
_composerUploads: new Map(),
|
||||
_composerOverlay: null,
|
||||
// Longest prompt Send accepts: the input frame limit minus both markers.
|
||||
// Longest prompt or shell paste Send accepts: the input frame limit minus both markers.
|
||||
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||
@@ -1205,23 +1206,35 @@ const KeyboardAccessoryBar = {
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
|
||||
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||
* expects bracketed input, so build the byte-identical sequence directly on
|
||||
* the durable session-bound path. Enter stays a separate delayed write
|
||||
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||
/** Deliver one complete prompt as an explicit bracketed paste
|
||||
* (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror
|
||||
* after terminal replay, even though the CLI still expects bracketed input,
|
||||
* so the frame is built directly rather than through terminal.paste().
|
||||
* Enter stays a separate delayed write because Codex drops keys sharing a
|
||||
* PTY read with a bracketed paste. */
|
||||
_sendComposedPrompt(sessionId, text) {
|
||||
if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false;
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** One bracketed-paste write on the durable session-bound path, the same
|
||||
* bytes a terminal paste produces, so the receiving shell or CLI takes a
|
||||
* multi-line block as one edit. Never presses Enter: callers decide.
|
||||
* `label` names the payload in the too-long toast. */
|
||||
_sendBracketedPaste(sessionId, text, label) {
|
||||
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
|
||||
// the single-line input transport and is what terminal.paste() emitted.
|
||||
const pasteText = text.replace(/\r?\n/g, '\r');
|
||||
// Paste markers inside the text are dropped first: a clipboard carrying
|
||||
// `ESC[201~` would end the bracket early and run the rest without Enter.
|
||||
const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
|
||||
if (pasteText.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return false;
|
||||
}
|
||||
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1247,7 +1260,11 @@ const KeyboardAccessoryBar = {
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
// A parked draft and text typed (or attached) on the prompt since are two
|
||||
// separate pieces of prose; one space keeps the draft's last word off the
|
||||
// prompt's first, unless either side already supplies the gap.
|
||||
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
|
||||
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
@@ -1269,7 +1286,7 @@ const KeyboardAccessoryBar = {
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1285,6 +1302,10 @@ const KeyboardAccessoryBar = {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
// Disabled alongside Send while an upload runs: this button deletes the draft
|
||||
// and closes the overlay, and the upload would then land its paths in a FRESH
|
||||
// draft, which the next open would prepend to the handed-back text.
|
||||
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
@@ -1292,6 +1313,7 @@ const KeyboardAccessoryBar = {
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
terminalButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
@@ -1318,7 +1340,7 @@ const KeyboardAccessoryBar = {
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1335,6 +1357,8 @@ const KeyboardAccessoryBar = {
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
|
||||
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
@@ -1366,9 +1390,8 @@ const KeyboardAccessoryBar = {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
.filter((file) => file && app._promptAttachKind(file));
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1382,7 +1405,55 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the prompt transport is single-line
|
||||
// and strips embedded newlines, so a multi-line draft would arrive mangled
|
||||
// and instead stays parked as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
return;
|
||||
}
|
||||
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
|
||||
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
|
||||
// as DOM lines on every repaint, so the transport's larger paste cap is not
|
||||
// the bound that matters here.
|
||||
if (text.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return;
|
||||
}
|
||||
// Cleared, never kept: the text now lives on the prompt, and a surviving
|
||||
// draft would be prepended to it the next time the composer opens.
|
||||
this._composerDrafts.delete(sessionId);
|
||||
close({ focusTerminal: true, preserveDraft: false });
|
||||
if (
|
||||
app._localEchoEnabled &&
|
||||
app._localEchoOverlay &&
|
||||
app.activeSessionId === sessionId &&
|
||||
!app._tilesOwnTerminal?.() &&
|
||||
!app._echoPassthroughSessions?.has(sessionId)
|
||||
) {
|
||||
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
|
||||
// Passthrough is excluded deliberately: such a session skips the overlay
|
||||
// branch on Enter, so text parked there would never be submitted. The tile
|
||||
// grid is excluded for the same reason _attachFromToolbar is: the main
|
||||
// terminal is parked and hidden then, and a tile's Enter cannot flush its
|
||||
// overlay, so the text goes to the PTY below instead of stranding there.
|
||||
app._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
// Write-through echo (codex) or no local echo at all: there is no buffer
|
||||
// to park text in, because the prompt the user sees IS the CLI's own
|
||||
// composer on the PTY. Checking for the overlay alone left this branch
|
||||
// doing nothing on codex, so the button looked dead there. That composer
|
||||
// may already hold text the open adopted nothing of, so lead with a
|
||||
// separator rather than fuse onto its last word (harmless when empty).
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
app._sendInputAsync(sessionId, ` ${text}`);
|
||||
}
|
||||
this._syncComposerDraftIndicator();
|
||||
});
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
@@ -1399,7 +1470,7 @@ const KeyboardAccessoryBar = {
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/* → camera / photo library / files), the most reliable
|
||||
* (accept=image/*,video/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
@@ -1408,19 +1479,22 @@ const KeyboardAccessoryBar = {
|
||||
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
// The shell this overlay was opened for: a tab switch while it is open must
|
||||
// not deliver the paste to whatever session is active at Send time.
|
||||
const sessionId = app.activeSessionId;
|
||||
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1429,22 +1503,30 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
// Only shell sessions reach this overlay (agent bars get Compose instead),
|
||||
// so the text lands at the prompt as one bracketed paste and is NOT run:
|
||||
// sent raw, every newline was an Enter and each line ran on its own as it
|
||||
// arrived. The user reviews the block and presses Enter themselves. A shell
|
||||
// that never enabled bracketed paste (dash, bash before 5.1) gets the
|
||||
// markers stripped by tmux, so there every line but the last still runs.
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
close();
|
||||
if (text) {
|
||||
app.sendInput(text);
|
||||
setTimeout(() => app.sendInput('\r'), 80);
|
||||
if (!text) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// A refused (too-long) paste keeps the dialog and its text for trimming.
|
||||
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// Filter to images and videos, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
// Upload to, and insert into, the shell this overlay was opened for.
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images, { sessionId });
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1458,10 +1540,8 @@ const KeyboardAccessoryBar = {
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -794,7 +794,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mobile-overview-row-wrap';
|
||||
wrap.appendChild(item);
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval, row));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -949,7 +949,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
|
||||
_buildMobileOverviewApprovalStrip(approval) {
|
||||
_buildMobileOverviewApprovalStrip(approval, row) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'mobile-overview-approval-strip';
|
||||
strip.setAttribute('data-i18n-skip', '');
|
||||
@@ -964,6 +964,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
strip.appendChild(btn);
|
||||
};
|
||||
// Alert-only (DeepSeek Harness / supervised hooks): open the session instead
|
||||
// of Approve/Deny that the answer route would refuse.
|
||||
if (approval.answerInTerminal) {
|
||||
addBtn('Answer in terminal', 'primary', () => {
|
||||
if (typeof this.selectSession === 'function') this.selectSession(approval.sessionId);
|
||||
else if (typeof this.openApprovalSession === 'function') this.openApprovalSession(approval.id);
|
||||
});
|
||||
return strip;
|
||||
}
|
||||
if (approval.options && approval.options.length) {
|
||||
for (const o of approval.options) {
|
||||
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
|
||||
|
||||
@@ -614,7 +614,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -626,15 +628,48 @@ html.mobile-init .file-browser-panel {
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
order: 5; /* Between Run Shell and settings gear */
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg {
|
||||
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
|
||||
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
|
||||
equal order source order keeps Attach on the left of the mic. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile-wrap {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
/* Relative box for the invisible file input that sits over the button and TAKES
|
||||
THE TAPS (see initImageInput): a native tap on the input is the only thing that
|
||||
opens the picker while the keyboard is up, and iOS anchors the picker popover to
|
||||
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
|
||||
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
|
||||
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
|
||||
.btn-attach-mobile-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-attach-mobile-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg,
|
||||
.btn-attach-mobile svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.btn-voice-mobile:active {
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile:active,
|
||||
/* The input above the button swallows the press, so the wrapper is what goes
|
||||
:active. Without this the only phone button with no press feedback is Attach. */
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: #2a3a5f;
|
||||
}
|
||||
|
||||
@@ -3245,13 +3280,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Attach's press feedback comes from its wrapper (the file input over the button
|
||||
takes the press). Kept out of the :is() list above on purpose: a descendant
|
||||
selector there would raise that list's specificity for every button in it. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
@@ -3552,7 +3596,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
}
|
||||
|
||||
/* Save + Close are the two ways out of the sheet (save-and-close vs
|
||||
discard-and-close), hit in the same corner with the same thumb, so here —
|
||||
discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
|
||||
and only here, since Save is header-only below 860px — they share a
|
||||
recessed tray and matching pill geometry instead of reading as a fat
|
||||
accent pill parked beside a stray × glyph. Tray colors come from skin
|
||||
|
||||
@@ -483,7 +483,7 @@ class NotificationManager {
|
||||
notif.read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to session if available
|
||||
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
||||
|
||||
+354
-11
@@ -30,10 +30,11 @@ const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
// would be dropped on the next save).
|
||||
const FILE_PREVIEW_PREF_KEYS = {
|
||||
mdRendered: 'codeman:filePreviewMdRendered',
|
||||
htmlRendered: 'codeman:filePreviewHtmlRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -4037,6 +4038,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* unknown workingDir answers false, leaving the historical path untouched.
|
||||
*/
|
||||
_isExternalPreviewPath(filePath, sessionId) {
|
||||
// `~/…` (a terminal or response-viewer link): only the server knows the home
|
||||
// of the session's host, and only the attachment route expands it.
|
||||
if (typeof filePath === 'string' && filePath.startsWith('~/')) return true;
|
||||
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
|
||||
const workingDir = this.sessions.get(sessionId)?.workingDir;
|
||||
if (!workingDir) return false;
|
||||
@@ -4083,8 +4087,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
|
||||
* granted folder (folder grid): rendered from the folder's by-name routes, so
|
||||
* paging through 300 renders never touches the attachment registry.
|
||||
*/
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
|
||||
if (!sessionId || !filePath) return;
|
||||
const folderFile = options.folderFile || null;
|
||||
// Prev/next only while browsing a folder grid; a preview opened from anywhere
|
||||
// else ends that browsing.
|
||||
if (!folderFile && this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(folderFile ? null : this._previewFolderPath(filePath, sessionId, attachmentId));
|
||||
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
const titleEl = this.$('filePreviewTitle');
|
||||
@@ -4093,6 +4108,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (!overlay || !bodyEl) return;
|
||||
|
||||
// Only the newest load may write: paging ←/→ through a folder starts the next
|
||||
// load while the previous one still awaits its fetch, and its late answer would
|
||||
// put the previous file's text under the next file's title.
|
||||
const load = (this._filePreviewLoad = (this._filePreviewLoad || 0) + 1);
|
||||
const stale = () => load !== this._filePreviewLoad;
|
||||
|
||||
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||
this._resetFilePreviewEdit();
|
||||
// Stop whatever the previous preview was playing. Overwriting innerHTML
|
||||
@@ -4106,6 +4127,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Same for the text-view toggles: they act on the text this load has not
|
||||
// fetched yet, and an image or PDF has nothing for them to toggle.
|
||||
this.filePreviewText = null;
|
||||
this.filePreviewPage = null;
|
||||
this.filePreviewPageRefused = false;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
|
||||
// Show overlay with loading state
|
||||
@@ -4116,14 +4139,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
|
||||
// HTML renders as a page: the server mints a sandboxed /html-view URL for the
|
||||
// file's directory (html-view-routes.ts), so relative CSS, scripts and images
|
||||
// resolve, and the page runs in an opaque origin that cannot reach Codeman.
|
||||
// The Page pill switches to the source view below (with its Edit button), and
|
||||
// a refused mint (remote case, broad folder, hidden directory) falls through
|
||||
// to that source view on its own.
|
||||
if (!attachmentId && rendersAsPage(filePath) && this._filePreviewPref('htmlRendered')) {
|
||||
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
|
||||
if (pageUrl) {
|
||||
footerEl.textContent = 'HTML';
|
||||
this.filePreviewPage = { filePath, sessionId };
|
||||
this.filePreviewDetachUrl = pageUrl;
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(pageUrl)}" title="${escapeHtml(filePath)}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"></iframe>`;
|
||||
this._updateFilePreviewToolbar('page');
|
||||
return;
|
||||
}
|
||||
// The pill would only retry a page the server just refused.
|
||||
this.filePreviewPageRefused = true;
|
||||
}
|
||||
|
||||
// Out-of-workspace path: mint an attachment id up front. Every branch below
|
||||
// talks to a workspace-confined route, so without this the image/PDF ones
|
||||
// render a broken frame and the text one reports a bare "File not found"
|
||||
// for a file that is sitting right there on disk.
|
||||
let externalError = '';
|
||||
let externalSize = 0;
|
||||
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
const external = await this._registerExternalPreview(filePath, sessionId);
|
||||
if (stale()) return;
|
||||
attachmentId = external.attachmentId || null;
|
||||
externalError = external.error || '';
|
||||
externalSize = external.size || 0;
|
||||
@@ -4140,9 +4185,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
const byIdBase = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`
|
||||
: folderFile
|
||||
? `/api/sessions/${sessionId}/folders/${encodeURIComponent(folderFile.folderId)}/files/${encodeURIComponent(folderFile.name)}`
|
||||
: null;
|
||||
this.filePreviewDetachUrl = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
byIdBase
|
||||
? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
@@ -4152,8 +4202,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
// A folder-grid file renders the same way, from its folder's by-name routes.
|
||||
if (byIdBase) {
|
||||
const base = CodemanBase.url(byIdBase);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||
@@ -4163,6 +4214,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Size when we just registered the file ourselves, so a path opened from a
|
||||
// link reads like a workspace preview instead of a bare "PNG". History
|
||||
// cards arrive with an id and no size and keep the short form.
|
||||
if (folderFile && !externalSize) externalSize = options.size || 0;
|
||||
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||
@@ -4190,6 +4242,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
|
||||
if (!res.ok) throw new Error('Failed to load attachment');
|
||||
const text = await res.text();
|
||||
if (stale()) return;
|
||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
// Markdown keeps every line the Range read returned: the byte bound is
|
||||
@@ -4201,13 +4254,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewContent = shown;
|
||||
// attachmentId: a card's filePath is the bare file name, so the
|
||||
// rebase pass must know there is no directory to resolve against.
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId: attachmentId || folderFile?.folderId };
|
||||
this._renderFilePreviewText();
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -4240,11 +4294,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
if (!res.ok) throw new Error('Failed to load image');
|
||||
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
|
||||
const svgText = await res.text();
|
||||
if (stale()) return;
|
||||
const blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
|
||||
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
|
||||
const img = bodyEl.querySelector('img');
|
||||
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
return;
|
||||
@@ -4260,6 +4317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
if (stale()) return;
|
||||
if (!result.success) throw new Error(result.error || 'Failed to load file');
|
||||
|
||||
const data = result.data;
|
||||
@@ -4299,14 +4357,266 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
console.error('Failed to preview file:', err);
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Folder grid =====
|
||||
|
||||
/**
|
||||
* Show every previewable file of a folder as a grid (absolute or `~/` path on the
|
||||
* session's host). The server grants the folder an id (`POST …/folders`), and the
|
||||
* grid and the previews it opens fetch files by `folderId + name` only.
|
||||
*
|
||||
* Opened from a clicked folder path: when the server finds a FILE there after all,
|
||||
* it falls back to the file preview, i.e. what a click did before folders were links.
|
||||
*/
|
||||
async openFolderGrid(folderPath, sessionId = this.activeSessionId) {
|
||||
if (!sessionId || !folderPath) return;
|
||||
const overlay = this.$('folderGridOverlay');
|
||||
const body = this.$('folderGridBody');
|
||||
if (!overlay || !body) return;
|
||||
this._bindFolderGridKeys();
|
||||
// Two quick opens (a chip, then its parent): only the newer one may render.
|
||||
const load = (this._folderGridLoad = (this._folderGridLoad || 0) + 1);
|
||||
let result = null;
|
||||
let status = 0;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/folders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
});
|
||||
status = res.status;
|
||||
result = await res.json().catch(() => null);
|
||||
} catch (err) {
|
||||
if (load !== this._folderGridLoad) return;
|
||||
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
|
||||
return;
|
||||
}
|
||||
if (load !== this._folderGridLoad) return;
|
||||
if (!result?.success || !result.data) {
|
||||
const reason = result?.error || `HTTP ${status}`;
|
||||
if (status === 400 && /not a folder/i.test(reason)) {
|
||||
this.openFilePreview(folderPath, sessionId);
|
||||
return;
|
||||
}
|
||||
this.showToast(`Cannot open folder ${folderPath}: ${reason}`, 'error');
|
||||
return;
|
||||
}
|
||||
this.folderGrid = { sessionId, ...result.data, index: null };
|
||||
this._renderFolderGrid();
|
||||
overlay.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
},
|
||||
|
||||
closeFolderGrid() {
|
||||
// A listing still on its way must not reopen the grid that was just closed.
|
||||
this._folderGridLoad = (this._folderGridLoad || 0) + 1;
|
||||
this.$('folderGridOverlay')?.classList.remove('visible');
|
||||
const body = this.$('folderGridBody');
|
||||
// Drop the tiles: lazy <img>s still loading would keep fetching otherwise.
|
||||
if (body) body.innerHTML = '';
|
||||
this.folderGrid = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
},
|
||||
|
||||
folderGridUp() {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || grid.path === '/') return;
|
||||
this.openFolderGrid(grid.path.slice(0, grid.path.lastIndexOf('/')) || '/', grid.sessionId);
|
||||
},
|
||||
|
||||
_renderFolderGrid() {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid) return;
|
||||
const title = this.$('folderGridTitle');
|
||||
const foldersEl = this.$('folderGridFolders');
|
||||
const body = this.$('folderGridBody');
|
||||
const footer = this.$('folderGridFooter');
|
||||
title.textContent = grid.path;
|
||||
title.title = grid.path;
|
||||
const upBtn = this.$('folderGridUpBtn');
|
||||
if (upBtn) upBtn.disabled = grid.path === '/';
|
||||
|
||||
foldersEl.innerHTML = '';
|
||||
for (const name of grid.folders) {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'folder-grid-chip';
|
||||
chip.textContent = `${name}/`;
|
||||
chip.onclick = () => this.openFolderGrid(`${grid.path === '/' ? '' : grid.path}/${name}`, grid.sessionId);
|
||||
foldersEl.appendChild(chip);
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
if (grid.files.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'folder-grid-empty';
|
||||
empty.textContent = 'No previewable files in this folder.';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
grid.files.forEach((file, index) => {
|
||||
const tile = document.createElement('button');
|
||||
tile.type = 'button';
|
||||
tile.className = 'folder-grid-tile';
|
||||
tile.title = `${file.name} • ${this.formatFileSize(file.size)}`;
|
||||
tile.dataset.index = String(index);
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'folder-grid-thumb';
|
||||
const label = file.extension.toUpperCase();
|
||||
const route = this._folderFileUrl(grid, file.name, '');
|
||||
// Images load themselves (lazily — a folder can hold hundreds of renders);
|
||||
// PDF/Office show their first page where the server can render one (local
|
||||
// folders), everything else a type label.
|
||||
const src =
|
||||
file.attachmentType === 'image'
|
||||
? `${route}/raw`
|
||||
: ['pdf', 'docx', 'pptx'].includes(file.extension)
|
||||
? `${route}/thumbnail`
|
||||
: null;
|
||||
if (src) {
|
||||
const img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.alt = file.name;
|
||||
img.src = src;
|
||||
img.onerror = () => {
|
||||
thumb.textContent = label;
|
||||
};
|
||||
thumb.appendChild(img);
|
||||
} else {
|
||||
thumb.textContent = label;
|
||||
}
|
||||
const name = document.createElement('span');
|
||||
name.className = 'folder-grid-name';
|
||||
name.textContent = file.name;
|
||||
tile.append(thumb, name);
|
||||
tile.onclick = () => this._openFolderGridFile(index);
|
||||
body.appendChild(tile);
|
||||
});
|
||||
|
||||
const count = `${grid.files.length} file${grid.files.length === 1 ? '' : 's'}`;
|
||||
const subfolders = grid.folders.length ? ` • ${grid.folders.length} folder${grid.folders.length === 1 ? '' : 's'}` : '';
|
||||
footer.textContent = `${count}${subfolders}${grid.truncated ? ' (folder too large — showing the first entries)' : ''}`;
|
||||
},
|
||||
|
||||
_folderFileUrl(grid, name, suffix) {
|
||||
return CodemanBase.url(
|
||||
`/api/sessions/${grid.sessionId}/folders/${encodeURIComponent(grid.folderId)}/files/${encodeURIComponent(name)}${suffix}`
|
||||
);
|
||||
},
|
||||
|
||||
_openFolderGridFile(index) {
|
||||
const grid = this.folderGrid;
|
||||
const file = grid?.files[index];
|
||||
if (!file) return;
|
||||
const body = this.$('folderGridBody');
|
||||
body?.querySelectorAll('.folder-grid-tile.current').forEach((t) => t.classList.remove('current'));
|
||||
const tile = body?.querySelector(`.folder-grid-tile[data-index="${index}"]`);
|
||||
tile?.classList.add('current');
|
||||
tile?.scrollIntoView({ block: 'nearest' });
|
||||
this.openFilePreview(`${grid.path === '/' ? '' : grid.path}/${file.name}`, grid.sessionId, null, {
|
||||
folderFile: { folderId: grid.folderId, name: file.name },
|
||||
size: file.size,
|
||||
});
|
||||
// After openFilePreview: it resets the index for previews from elsewhere.
|
||||
grid.index = index;
|
||||
grid.lastIndex = index;
|
||||
this._updateFolderPreviewNav(null);
|
||||
},
|
||||
|
||||
/** Page through the open folder from its preview (‹ › buttons, ←/→). */
|
||||
stepFolderPreview(delta) {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || grid.index === null || grid.files.length === 0) return;
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
const next = (grid.index + delta + grid.files.length) % grid.files.length;
|
||||
this._openFolderGridFile(next);
|
||||
},
|
||||
|
||||
/** "Show folder" in the preview: the grid of the previewed file's folder. */
|
||||
openFolderOfPreview() {
|
||||
const target = this.filePreviewFolderTarget;
|
||||
if (!target) return;
|
||||
this.closeFilePreview();
|
||||
this.openFolderGrid(target.dir, target.sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* The folder a previewed file lives in, or null when that is unknown — an
|
||||
* attachment card carries only a bare file name.
|
||||
*/
|
||||
_previewFolderPath(filePath, sessionId, attachmentId) {
|
||||
if (typeof filePath !== 'string' || (attachmentId && !filePath.includes('/'))) return null;
|
||||
const workingDir = this.sessions?.get?.(sessionId)?.workingDir;
|
||||
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(filePath, workingDir) : filePath;
|
||||
if (!resolved.startsWith('/') && !resolved.startsWith('~/')) return null;
|
||||
const cut = resolved.lastIndexOf('/');
|
||||
return { dir: resolved.slice(0, cut) || '/', sessionId };
|
||||
},
|
||||
|
||||
/** Show prev/next while browsing a folder grid, else "Show folder" when the file's folder is known. */
|
||||
_updateFolderPreviewNav(folderTarget) {
|
||||
const browsing = !!this.folderGrid && this.folderGrid.index !== null && this.folderGrid.files.length > 1;
|
||||
const prev = this.$('filePreviewPrevBtn');
|
||||
const next = this.$('filePreviewNextBtn');
|
||||
if (prev) prev.hidden = !browsing;
|
||||
if (next) next.hidden = !browsing;
|
||||
if (folderTarget !== undefined) this.filePreviewFolderTarget = folderTarget;
|
||||
const inGrid = !!this.folderGrid && this.folderGrid.index !== null;
|
||||
const folderBtn = this.$('filePreviewFolderBtn');
|
||||
if (folderBtn) folderBtn.hidden = inGrid || !this.filePreviewFolderTarget;
|
||||
},
|
||||
|
||||
/**
|
||||
* ←/→ page the folder preview, Escape steps back (preview → grid → closed).
|
||||
* Capture phase + stopPropagation: the terminal keeps keyboard focus under the
|
||||
* overlays, and an arrow key that reached xterm would be typed into the session.
|
||||
*/
|
||||
_bindFolderGridKeys() {
|
||||
if (this._folderGridKeysBound) return;
|
||||
this._folderGridKeysBound = true;
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(ev) => {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||
const previewOpen = this.$('filePreviewOverlay')?.classList.contains('visible');
|
||||
const gridOpen = this.$('folderGridOverlay')?.classList.contains('visible');
|
||||
if (!gridOpen) return;
|
||||
const t = ev.target;
|
||||
const typing =
|
||||
t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')));
|
||||
if (typing || this.filePreviewEdit) return;
|
||||
let handled = true;
|
||||
if (previewOpen && grid.index !== null && ev.key === 'ArrowLeft') this.stepFolderPreview(-1);
|
||||
else if (previewOpen && grid.index !== null && ev.key === 'ArrowRight') this.stepFolderPreview(1);
|
||||
// Grid alone: an arrow opens the last viewed tile (or the first), so it
|
||||
// pages the folder instead of landing in the terminal behind the grid.
|
||||
else if (!previewOpen && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && grid.files.length)
|
||||
this._openFolderGridFile(grid.lastIndex ?? 0);
|
||||
else if (ev.key === 'Escape') {
|
||||
if (previewOpen) this.closeFilePreview();
|
||||
else this.closeFolderGrid();
|
||||
} else handled = false;
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
closeFilePreview() {
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
this._resetFilePreviewEdit();
|
||||
// Back on the grid (if one is open underneath): its current tile stays marked.
|
||||
if (this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -4571,8 +4881,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Show the toggles that apply to the current view: MD for a markdown file in
|
||||
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
|
||||
* image, media, PDF, edit mode) hides all three.
|
||||
* either view, Page for an HTML file in either view (`'page'` = rendered),
|
||||
* Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
|
||||
* PDF, edit mode) hides them all.
|
||||
*/
|
||||
_updateFilePreviewToolbar(view) {
|
||||
const set = (id, shown, pressed) => {
|
||||
@@ -4583,10 +4894,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
};
|
||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
||||
// Page: shown on a rendered HTML page, and on its source view when that view
|
||||
// can be re-rendered (a bare attachment-card name has no path to mint from).
|
||||
const text = this.filePreviewText;
|
||||
const htmlSource =
|
||||
view === 'text' &&
|
||||
!this.filePreviewPageRefused &&
|
||||
rendersAsPage(text?.filePath) &&
|
||||
(!text.attachmentId || text.filePath.startsWith('/'));
|
||||
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
|
||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
||||
},
|
||||
|
||||
/** Mint the sandboxed page URL for an HTML file; null when the server refuses. */
|
||||
async _mintHtmlViewUrl(filePath, sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath }),
|
||||
});
|
||||
const result = await res.json().catch(() => ({}));
|
||||
return res.ok && result.success ? CodemanBase.url(result.data.url) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Page pill: flip between the rendered page and the source view of an HTML file. */
|
||||
toggleFilePreviewPage() {
|
||||
const target = this.filePreviewPage || this.filePreviewText;
|
||||
if (!target) return;
|
||||
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
|
||||
return this.openFilePreview(target.filePath, target.sessionId);
|
||||
},
|
||||
|
||||
toggleFilePreviewMd() {
|
||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
||||
this._renderFilePreviewText();
|
||||
|
||||
@@ -621,9 +621,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** Ensure a newly-created session is visible without waiting for the SSE event.
|
||||
* The POST response and session:created can arrive in either order, so the
|
||||
* normal idempotent SSE handler remains the single state-upsert path. */
|
||||
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
|
||||
* normal idempotent SSE handler remains the single state-upsert path.
|
||||
* `tabLayout` is the layout a grouped creation returns (see run()'s
|
||||
* `tabGroupId`): adopting it first draws the new tab inside its group. */
|
||||
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) {
|
||||
if (!sessionId) return;
|
||||
if (tabLayout) this._applyTabLayout?.(tabLayout);
|
||||
|
||||
let session = sessionSnapshot;
|
||||
if (!session && !this.sessions?.has(sessionId)) {
|
||||
@@ -643,8 +646,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderSessionTabsImmediate?.();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
|
||||
async run() {
|
||||
/**
|
||||
* Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity).
|
||||
* `options` comes from the tab group menu's "New session": `count` overrides
|
||||
* the instance steppers for this launch only, `tabGroupId` rides every create
|
||||
* request so the server places the session in that group.
|
||||
*/
|
||||
async run(options = {}) {
|
||||
if (this._runInFlight) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
@@ -659,12 +667,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
return await this.runShell(options);
|
||||
}
|
||||
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
|
||||
return await this.runClaude();
|
||||
return await this.runClaude(options);
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
return await this._runCliMode(mode, options);
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
@@ -1910,9 +1918,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
async runClaude(options = {}) {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = this._readTabCount();
|
||||
const tabCount = this._readTabCount(options.count);
|
||||
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||
@@ -1972,6 +1981,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const quickStartBody = JSON.stringify({
|
||||
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
|
||||
...group,
|
||||
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
|
||||
});
|
||||
const doQuickStart = async () => {
|
||||
@@ -2000,7 +2010,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
|
||||
@@ -2046,6 +2056,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir, name,
|
||||
...group,
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(advisorModel ? { advisorModel } : {}),
|
||||
@@ -2059,7 +2070,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
firstSessionId = sessionIds[0];
|
||||
@@ -2128,11 +2139,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runShell() {
|
||||
async runShell(options = {}) {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Run Shell reads the toolbar's one instance stepper, like every other run*();
|
||||
// its own second `− 1 +` group (#shellCount) was removed (#428).
|
||||
const shellCount = this._readTabCount();
|
||||
const shellCount = this._readTabCount(options.count);
|
||||
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||
@@ -2178,11 +2190,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}`, ...group })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
@@ -2208,7 +2220,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
|
||||
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name, ...group })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -2216,7 +2228,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
|
||||
@@ -2257,8 +2269,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* their try block, to put the count in the opening banner: `#tabCount` ships
|
||||
* unconditionally today, but a throw here would escape the launch-error path.
|
||||
*/
|
||||
_readTabCount() {
|
||||
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1));
|
||||
_readTabCount(override) {
|
||||
return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1)));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2291,7 +2303,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
if (!data.success) throw new Error(data.error || `Failed to start ${label}`);
|
||||
customModelAnswered = true;
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
|
||||
if (!firstSessionId) firstSessionId = data.data.sessionId;
|
||||
}
|
||||
if (tabCount > 1) {
|
||||
@@ -2309,7 +2321,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* menu call them directly by name (`app.runOpenCode()` etc.), and several
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
async _runCliMode(mode, options = {}) {
|
||||
const catalogEntry = registryCliById(mode);
|
||||
const entry = RUN_MODE_LAUNCH[mode] ||
|
||||
(catalogEntry && {
|
||||
@@ -2326,7 +2338,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const tabCount = this._readTabCount(options.count);
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||
);
|
||||
@@ -2364,6 +2376,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseName,
|
||||
mode,
|
||||
sessionName,
|
||||
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
|
||||
...(isRemote ? {} : {
|
||||
...(entry.buildConfig(globalSettings) || {}),
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -2473,6 +2486,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tab) tab.classList.toggle('tab-show-detach', !!on);
|
||||
},
|
||||
|
||||
/** Copy the open session's working folder (Session Options > Session > Working folder). */
|
||||
async copySessionPath() {
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (!session?.workingDir) return;
|
||||
const ok = await this._copyText(session.workingDir);
|
||||
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
|
||||
},
|
||||
|
||||
openSessionOptions(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -2534,6 +2555,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Populate auto-resume on usage limit (token pause control)
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -2552,6 +2574,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
||||
}
|
||||
|
||||
// Working folder: the path the CLI was launched in on the Codeman host
|
||||
const _pathEl = document.getElementById('modalSessionPath');
|
||||
_pathEl.textContent = session.workingDir || '';
|
||||
_pathEl.title = session.workingDir || '';
|
||||
document.getElementById('modalSessionPathCopy').disabled = !session.workingDir;
|
||||
// A remote session's workingDir is the REMOTE path; a docker session's is the
|
||||
// host bind-mount source the container sees.
|
||||
document.getElementById('modalSessionPathDesc').textContent = session.remote
|
||||
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
|
||||
: session.docker
|
||||
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
|
||||
: "Where this session's CLI runs, on the machine hosting Codeman.";
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
const colorPicker = document.getElementById('sessionColorPicker');
|
||||
@@ -2784,6 +2819,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Session Options → Respawn → "Prompt cache": the main conversation's cache state as
|
||||
* Claude Code reports it on the statusline (`session.promptCache`, v2.1.251+), worded by
|
||||
* the pure `CodemanPromptCache.format` (constants.js). Refreshed on open and on the
|
||||
* `session:promptCache` SSE event while the modal shows that session.
|
||||
*/
|
||||
updatePromptCacheStatus(sessionId) {
|
||||
const el = document.getElementById('promptCacheStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter);
|
||||
},
|
||||
|
||||
_onSessionPromptCache(data) {
|
||||
const { sessionId, ...promptCache } = data;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.promptCache = promptCache;
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
|
||||
@@ -442,6 +442,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||
// Spawned tabs follow their parent: synced (the server places the tab), default OFF.
|
||||
document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
@@ -487,6 +489,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
@@ -509,6 +512,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked =
|
||||
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
|
||||
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
|
||||
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
|
||||
const tileDetachItem = document.getElementById('appSettingsTileDetachItem');
|
||||
if (tileDetachItem) tileDetachItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||
@@ -1226,15 +1234,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html) => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; }
|
||||
const show = (html, hint = '') => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; }
|
||||
};
|
||||
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
|
||||
if (!this._mcpSyncSavedOn) {
|
||||
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
|
||||
show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first');
|
||||
return;
|
||||
}
|
||||
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
|
||||
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file (and GitHub Copilot CLI\'s, when it is installed)? Env values and headers on those servers are copied too.')) return;
|
||||
show('Working…');
|
||||
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
|
||||
let body = null;
|
||||
@@ -2512,7 +2520,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply button: the same save as Save, but the modal stays open and the MCP sync group (its
|
||||
* Preview and Sync need the saved flag) and the CLI management writes are refreshed in place, so
|
||||
* turning either on needs no close-and-reopen. It is a wrapper rather than an option on
|
||||
* saveAppSettings() so that function's signature (which tests locate by text) stays as it was.
|
||||
*
|
||||
* `_keepSettingsOpenOnce` is the one-shot intent and `_applyInFlight` the double-click guard:
|
||||
* saveAppSettings() consumes the intent before its first await, so a Save clicked while an Apply
|
||||
* is still in flight is an ordinary Save and closes the modal.
|
||||
*/
|
||||
async applyAppSettings() {
|
||||
if (this._applyInFlight) return;
|
||||
this._applyInFlight = true;
|
||||
this._keepSettingsOpenOnce = true;
|
||||
try {
|
||||
await this.saveAppSettings();
|
||||
} finally {
|
||||
this._applyInFlight = false;
|
||||
this._keepSettingsOpenOnce = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveAppSettings() {
|
||||
const keepOpen = this._keepSettingsOpenOnce === true;
|
||||
this._keepSettingsOpenOnce = false;
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
@@ -2557,6 +2589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||
spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
@@ -2583,6 +2616,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
@@ -2590,6 +2624,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
document.getElementById('appSettingsSessionSidebarFontSize').value
|
||||
@@ -2841,6 +2876,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
showTabDetachButton: _tdb,
|
||||
// Detach Tiles: what a tile drag does is a property of this device's
|
||||
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
|
||||
tileDetachEnabled: _tde,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
// Desktop-only tab decoration, per-device, and likewise absent from the
|
||||
@@ -2857,6 +2895,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
let serverSaved = false;
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -2874,10 +2913,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const cb = document.getElementById('appSettingsTunnelEnabled');
|
||||
if (cb) cb.checked = false;
|
||||
this.closeAppSettings();
|
||||
if (!keepOpen) this.closeAppSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
|
||||
// evidence the server kept the flags the Apply refresh below reads.
|
||||
serverSaved = !!res?.ok;
|
||||
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
|
||||
@@ -2889,7 +2932,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else {
|
||||
this.showToast('Settings saved', 'success');
|
||||
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
|
||||
}
|
||||
|
||||
// Show tunnel-specific feedback if toggled on
|
||||
@@ -2901,9 +2944,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Settings saved locally', 'warning');
|
||||
}
|
||||
|
||||
// Only when the settings PUT landed: after a 400 or a dropped connection the server still has the
|
||||
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
|
||||
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
|
||||
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else {
|
||||
} else if (!keepOpen) {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
@@ -2924,6 +2971,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
|
||||
* reopening the modal. openAppSettings does the same on open; this is the part of it that
|
||||
* a save can change, without touching what the user is editing or the scroll position.
|
||||
*/
|
||||
_refreshSettingsAfterApply(settings) {
|
||||
// The MCP routes read the saved flag, so switching it on is only usable from now.
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
const out = this.$('mcpSyncResult');
|
||||
if (this._mcpSyncSavedOn && out && out.dataset?.hint === 'save-first') {
|
||||
out.style.display = 'none';
|
||||
out.innerHTML = '';
|
||||
}
|
||||
this.applyMcpSyncVisibility();
|
||||
this.applyCustomModelEndpointsVisibility();
|
||||
this.applyCliManagementVisibility();
|
||||
this._applyDoctorAdminGate();
|
||||
},
|
||||
|
||||
// Load model configuration from server for the settings modal
|
||||
async loadModelConfigForSettings() {
|
||||
try {
|
||||
@@ -3541,6 +3607,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
showTabCliLogos: true,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
@@ -3677,6 +3744,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
|
||||
},
|
||||
|
||||
/**
|
||||
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
|
||||
* device). Anything but an explicit false reads as on, the same test the
|
||||
* pre-paint script in index.html applies, so a reload and a Save never
|
||||
* disagree about an odd stored value.
|
||||
*/
|
||||
tabCliLogosEnabled(settings) {
|
||||
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
@@ -3837,6 +3914,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Tiles button: same gate and backstop as Split (tile-grid.js).
|
||||
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
|
||||
this._applyTileGridButtonVisibility?.(showTileGridButton);
|
||||
// Detach Tiles changes what a tile's header does (and says): repaint the handles.
|
||||
this._renderTileChrome?.();
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
@@ -3932,6 +4011,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
})
|
||||
: 'horizontal';
|
||||
|
||||
// The search box lives in the rail: a search left applied after the list
|
||||
// moves out would hide tabs with no box to clear it from.
|
||||
if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.();
|
||||
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
|
||||
root.setAttribute('data-tab-orientation', orientation);
|
||||
@@ -3964,6 +4047,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
|
||||
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
|
||||
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
|
||||
// of `changed` below: the logo spans are always in the markup. It still
|
||||
// resizes every agent tab, which the tail of this function settles.
|
||||
const previousLogos = root.dataset.tabLogos;
|
||||
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
|
||||
root.dataset.tabLogos = logos;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
@@ -4013,6 +4104,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
} else if (previousLogos !== logos) {
|
||||
// A logo flip narrows or widens every agent tab with no render behind
|
||||
// it, so re-take what a render would have: the strip's one-row wrap
|
||||
// decision and the lines anchored to tab rects (lineage, subagent
|
||||
// connectors). A header that gains or loses a row resizes the terminal
|
||||
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
|
||||
this.updateTabOverflowMode?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
@@ -4261,7 +4360,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
@@ -4273,6 +4372,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
'showTileGridButton',
|
||||
'tileDetachEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
|
||||
@@ -171,6 +171,17 @@ async function loadWorkbook(bytes) {
|
||||
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||
// than silently expanding again.
|
||||
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||
// ExcelJS follows a worksheet's comment, VML, table and drawing links
|
||||
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
|
||||
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
|
||||
// once on the parsed model; repair the model first (core.repairExcelJsModel).
|
||||
// Throws if a future ExcelJS renames reconcile, rather than failing again.
|
||||
const reconcile = nextWorkbook.xlsx.reconcile;
|
||||
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
|
||||
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
|
||||
core.repairExcelJsModel(model);
|
||||
return reconcile.call(this, model, options);
|
||||
};
|
||||
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||
// object (a whole-column dropdown is a million), and the preview never shows
|
||||
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||
const SPREADSHEET_ASSET_VERSION = 'fd81c01bef7b';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user