Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d59e59e37 | ||
|
|
019118ba9d | ||
|
|
7a4d520cd0 | ||
|
|
08d7fa1985 | ||
|
|
0c6cbd0718 | ||
|
|
8b2cb49940 | ||
|
|
fb5c1bfca5 | ||
|
|
7b95856cc5 | ||
|
|
3a40dae1dd | ||
|
|
a81d344fb5 | ||
|
|
16980d644d | ||
|
|
b538429f6e | ||
|
|
7689a43782 | ||
|
|
950450b137 | ||
|
|
55cd59a346 | ||
|
|
209a539489 | ||
|
|
71e6cfa0a2 | ||
|
|
be9454a29b | ||
|
|
29c2701f83 | ||
|
|
f152d551d1 | ||
|
|
6844cccd18 | ||
|
|
89177651a6 | ||
|
|
ac8e0bdcbb | ||
|
|
9d6c010819 | ||
|
|
183efacc93 | ||
|
|
4a94eb9ba7 | ||
|
|
e589004926 | ||
|
|
5f373a9324 | ||
|
|
ac5f2aa3f4 | ||
|
|
2881ef5fa5 | ||
|
|
9644892a5a | ||
|
|
310a4c2489 | ||
|
|
29d8ca16b4 | ||
|
|
92e54163e0 | ||
|
|
39a91a0c7f | ||
|
|
cea199651a | ||
|
|
656ab98934 | ||
|
|
e6382ecfc0 | ||
|
|
4d6d187883 | ||
|
|
d3fc07ee28 | ||
|
|
b2de04c730 | ||
|
|
ed6f5f6856 | ||
|
|
eada647d7b | ||
|
|
233d5f25c4 | ||
|
|
cf9022ac9a | ||
|
|
87f1c9ccd4 | ||
|
|
586aafa8de | ||
|
|
d8c23210bb | ||
|
|
7c3877016b | ||
|
|
7c4ad22f9c | ||
|
|
dd7a5b9275 | ||
|
|
217c90b6a1 | ||
|
|
93642fa616 | ||
|
|
6db526dbad | ||
|
|
e6deab98a6 | ||
|
|
5e0cea6ec8 | ||
|
|
2cc6ea1cfd | ||
|
|
c5ddb77adc | ||
|
|
d2d2ba3e4a | ||
|
|
33ad314bea | ||
|
|
3f4af2aa2e | ||
|
|
cf11a253d3 | ||
|
|
17824f1847 | ||
|
|
b3d3c647cf | ||
|
|
1b3f40bba7 | ||
|
|
fc7ffe1ad8 | ||
|
|
53f61ca539 | ||
|
|
3be03c6896 | ||
|
|
ba36cc36d4 | ||
|
|
7aecbd29df | ||
|
|
b59145effd | ||
|
|
f12b5ac88b | ||
|
|
5206a044bb | ||
|
|
abc8598100 | ||
|
|
6ba311f38d | ||
|
|
decd263b17 | ||
|
|
82aeeaec02 | ||
|
|
ad57394618 | ||
|
|
fda1897109 | ||
|
|
eceaecf076 | ||
|
|
6f87186deb | ||
|
|
aa4217b8b1 | ||
|
|
c75d62ce04 | ||
|
|
c2e55fc210 | ||
|
|
24e51a3b8e | ||
|
|
0481db569d | ||
|
|
6d862d5323 | ||
|
|
57d5c7b1c2 | ||
|
|
9c63c41283 | ||
|
|
26487f0ec5 | ||
|
|
8da4a07a60 | ||
|
|
3a0cee6b90 | ||
|
|
5e2e9bb833 | ||
|
|
28708cfa14 | ||
|
|
c638c88739 | ||
|
|
a4511a0e48 | ||
|
|
be3436f5b3 | ||
|
|
330203c08b | ||
|
|
9d38cbf51a | ||
|
|
ecd577157b | ||
|
|
c614241c48 | ||
|
|
1def7de146 | ||
|
|
82c87f56d1 | ||
|
|
cdb3c34ae0 | ||
|
|
eb5d982c38 | ||
|
|
e39a750749 | ||
|
|
daf7330d6e | ||
|
|
41643f2b38 | ||
|
|
37ddcbe2f0 | ||
|
|
18c8b5c280 | ||
|
|
7409ad2655 | ||
|
|
a96a94fb7e | ||
|
|
24a73ecd81 | ||
|
|
312a8faa06 | ||
|
|
4fe843a94e | ||
|
|
155372f7a8 | ||
|
|
f855b5d274 | ||
|
|
ccd52583e2 | ||
|
|
2c267276c3 | ||
|
|
efb3fa112d | ||
|
|
fe1acd625e | ||
|
|
432bd5fc0a | ||
|
|
f79f530f93 | ||
|
|
48f54ec090 | ||
|
|
34f211538a | ||
|
|
0868286661 | ||
|
|
d7140c32b4 | ||
|
|
af4e3e6ed7 | ||
|
|
e86c3d1ed3 | ||
|
|
1ee566e7a1 | ||
|
|
1105d646f3 | ||
|
|
ff2f81541a | ||
|
|
02c65e988a | ||
|
|
4502bfe8a9 | ||
|
|
ce6e4cc6e6 | ||
|
|
e5417310f8 | ||
|
|
28df21f4bb | ||
|
|
5c08e29a08 | ||
|
|
7f26b4ba46 | ||
|
|
5c357d699f | ||
|
|
c2340ae88a | ||
|
|
0f5613ba46 | ||
|
|
a049c69cbb | ||
|
|
2c066eb2f0 | ||
|
|
3bfb3ddfc5 | ||
|
|
7e2b9ed8f6 | ||
|
|
7d8c188f83 | ||
|
|
f3b2080e69 | ||
|
|
a58991e6d8 | ||
|
|
3fe5d1b278 | ||
|
|
112c533ac7 | ||
|
|
e7d661158b | ||
|
|
db168cdbe5 | ||
|
|
b539780f33 | ||
|
|
40560aced1 | ||
|
|
09d3b9a3bf | ||
|
|
34cbd5015b | ||
|
|
a1d07a56e5 | ||
|
|
2accd804f9 | ||
|
|
6644962d70 | ||
|
|
1568e489eb | ||
|
|
4ee382832a | ||
|
|
1296223403 | ||
|
|
deb11bade2 | ||
|
|
bd3c368512 | ||
|
|
42147d30c0 | ||
|
|
e308e99f05 | ||
|
|
382d7dd406 | ||
|
|
f5acf19a11 | ||
|
|
3904428a4f | ||
|
|
aa8e06c162 | ||
|
|
9b28c277f0 | ||
|
|
f7a41b3fae | ||
|
|
d630e62114 | ||
|
|
b1def488c4 | ||
|
|
b8dbef6241 | ||
|
|
f01f54e614 | ||
|
|
6fa807c2c3 | ||
|
|
421482e121 | ||
|
|
a898253089 | ||
|
|
d2a1d14eac | ||
|
|
17bc2f02e9 | ||
|
|
354c4641a9 | ||
|
|
bb4e7943c5 | ||
|
|
03629c966e | ||
|
|
5ba729fcbb | ||
|
|
5a0018fc86 | ||
|
|
9230b53ccd | ||
|
|
66c8fef97f | ||
|
|
45492a3013 | ||
|
|
4bb333e9bc | ||
|
|
fcec77131c | ||
|
|
4ad283c647 | ||
|
|
dbaf328c0a | ||
|
|
78bfe53dc7 | ||
|
|
7debebb7b3 | ||
|
|
64298101b0 | ||
|
|
45ca9c347b | ||
|
|
e702f3c151 | ||
|
|
aecada8c56 | ||
|
|
adeb22d7b2 | ||
|
|
20c2561a45 | ||
|
|
2b20288ca0 | ||
|
|
9209922ea4 | ||
|
|
2e25bfa9e0 | ||
|
|
3104e9945b | ||
|
|
21ae48a5c8 | ||
|
|
661fe3dc13 | ||
|
|
284f86b740 | ||
|
|
c481bf4f47 | ||
|
|
c6e13e4fcf | ||
|
|
969f273fec | ||
|
|
8392854619 | ||
|
|
d044406f8f | ||
|
|
c30128d3f0 | ||
|
|
fc6e911888 | ||
|
|
16e44aa1d1 | ||
|
|
218b03ceb7 | ||
|
|
8ce2e5acbb | ||
|
|
4bfe239083 | ||
|
|
9f9bdbb671 | ||
|
|
a07c663ca9 | ||
|
|
4b2e6a7c83 | ||
|
|
17eea3c230 | ||
|
|
c101b70678 | ||
|
|
b9ce850a2c | ||
|
|
fbab0ffbf8 | ||
|
|
e6eb3dd849 | ||
|
|
c54867d9f0 | ||
|
|
0746cffe6d | ||
|
|
6cbaf3f7b6 | ||
|
|
be7328c8eb | ||
|
|
1202e9baa2 | ||
|
|
a24b548389 | ||
|
|
6af38abc76 | ||
|
|
55b526bc1e | ||
|
|
4558536676 | ||
|
|
b965c3d346 | ||
|
|
ec5e4aa3e1 | ||
|
|
7990249e2d | ||
|
|
6d72b38db4 | ||
|
|
50e0d22def | ||
|
|
d7f6047529 | ||
|
|
02387b5b16 | ||
|
|
409fd658f2 | ||
|
|
5650be5200 | ||
|
|
00440c02e1 | ||
|
|
caf5248e22 | ||
|
|
5da25f782b | ||
|
|
66da91f78b | ||
|
|
107e87e457 | ||
|
|
dfb9f32e23 | ||
|
|
c848e7cf27 | ||
|
|
6c5b4a7a25 | ||
|
|
be04d3e5e0 | ||
|
|
09daf0fe49 | ||
|
|
dd01ea9927 | ||
|
|
39d87e363f | ||
|
|
b7fafb1c16 | ||
|
|
d2e72143e8 | ||
|
|
21beacf700 | ||
|
|
0d1b91188d | ||
|
|
fa9d5879b3 | ||
|
|
f1e5b82ecc | ||
|
|
c206d10e3a | ||
|
|
5a58d272ea | ||
|
|
6bb16fdad6 | ||
|
|
d331db1141 | ||
|
|
dbff114dda | ||
|
|
65e8271fbe | ||
|
|
b4618bb853 | ||
|
|
de1b48a63f | ||
|
|
6fedbd1b09 | ||
|
|
83d0caa209 | ||
|
|
13b2989886 | ||
|
|
88447e6c2b | ||
|
|
6adf750c50 | ||
|
|
1a04a75c3d | ||
|
|
4341d3dca8 | ||
|
|
fca7acd05f | ||
|
|
59eb509d47 | ||
|
|
526d396492 | ||
|
|
3ae22f64a4 | ||
|
|
90649fc363 | ||
|
|
f21ab39a89 | ||
|
|
0c71b753ef | ||
|
|
310f20b288 | ||
|
|
55cafc282f | ||
|
|
bb5fd5ee97 | ||
|
|
bebf0db792 | ||
|
|
e3dfbf6591 | ||
|
|
9b0d305223 | ||
|
|
aad9c248dc | ||
|
|
4c2fdd5f5a | ||
|
|
ab7e89873f | ||
|
|
c36be7bb94 | ||
|
|
51b6be3e7a | ||
|
|
0ae5ce017a | ||
|
|
ab96e74e69 | ||
|
|
d00229ee29 | ||
|
|
7d3e27fb6d | ||
|
|
c1811fd716 | ||
|
|
2d0ffb71aa | ||
|
|
d65ee4f89d | ||
|
|
e85b4f34dd | ||
|
|
bfab172608 | ||
|
|
4edb7b8f80 | ||
|
|
0b122e2c76 | ||
|
|
0720526b64 | ||
|
|
cbb1435a46 | ||
|
|
8e4606c57b |
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/codeman",
|
"source": "./plugins/codeman",
|
||||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.35.0",
|
"version": "1.41.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ npm run test:all # literally everything, environmental failures included
|
|||||||
|
|
||||||
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
|
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
|
||||||
|
|
||||||
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
|
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; 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.
|
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Browser suite
|
||||||
|
|
||||||
|
# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts),
|
||||||
|
# so a browser-only regression can merge green. This job runs that suite on a schedule and on
|
||||||
|
# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day
|
||||||
|
# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a
|
||||||
|
# push or a PR.
|
||||||
|
#
|
||||||
|
# Needs: chromium (installed below), tmux, and the live server the tests start themselves.
|
||||||
|
# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and
|
||||||
|
# codex-predictive-echo (needs a real codex binary; it also skips itself without one).
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '29 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
browser:
|
||||||
|
name: Playwright browser suite
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install tmux
|
||||||
|
run: |
|
||||||
|
if ! command -v tmux >/dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y tmux
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install chromium
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run the browser suite
|
||||||
|
run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts
|
||||||
@@ -219,6 +219,8 @@ jobs:
|
|||||||
run: npx vitest run
|
run: npx vitest run
|
||||||
working-directory: packages/xterm-zerolag-input
|
working-directory: packages/xterm-zerolag-input
|
||||||
|
|
||||||
|
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
|
||||||
|
# that job is informational and never gates a push or a PR.
|
||||||
# Note: three suites are excluded from CI, each with its own local runner:
|
# Note: three suites are excluded from CI, each with its own local runner:
|
||||||
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
||||||
# codex binary for codex-predictive-echo)
|
# codex binary for codex-predictive-echo)
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ todo.md
|
|||||||
@fix_plan.md
|
@fix_plan.md
|
||||||
readme-preview.mjs
|
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/
|
.claude-images/
|
||||||
|
|
||||||
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
|
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ src/web/public/settings-ui.js
|
|||||||
src/web/public/sw.js
|
src/web/public/sw.js
|
||||||
src/web/public/terminal-ui.js
|
src/web/public/terminal-ui.js
|
||||||
src/web/public/voice-input.js
|
src/web/public/voice-input.js
|
||||||
src/web/public/upload.html
|
|
||||||
scripts/remotion/
|
scripts/remotion/
|
||||||
|
|
||||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ Quick pointers:
|
|||||||
|
|
||||||
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
- 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
|
- 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 ports must pick a unique `const PORT =`
|
- 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): ...`)
|
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||||
- Never commit secrets or local state from `~/.codeman/`
|
- Never commit secrets or local state from `~/.codeman/`
|
||||||
|
|||||||
@@ -1,5 +1,102 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- ### Thanks
|
||||||
|
- @opticon454 for four PRs in this release: the end of Android autocorrect duplicating a line (#541), a Run dropdown that fits and scrolls on any screen (#542), Git status that copes with big folders and slow shares (#543), and a nightly browser-suite workflow plus the three stale browser tests it needed (#540). Thanks also for confirming #550 on a live install.
|
||||||
|
- @JDProfresh, a first-time contributor, for four PRs: Respawn and Ralph back in Claude's Session Options (#550), removing the broken tunnel Upload URL page (#551), the wiki's Contributing page catching up with CONTRIBUTING.md (#552), and a remote-wake test that no longer pins its budget to the millisecond (#559).
|
||||||
|
- @Randalix for making opencode tabs selectable and scrollable again (#555), and for the server reporting the port it really bound, which let the test suite move off fixed ports (#556).
|
||||||
|
- @shenlvkang-collab for Default Codex model and reasoning effort settings (#546), and for the opt-in `window.CodemanHost` bridge that lets a native wrapper app pop a session or a file preview out into a window of its own (#432).
|
||||||
|
- @aakhter for `.xlsx` spreadsheets in the File Viewer (#502), parsed entirely in the browser behind admission limits that held up to round after round of review.
|
||||||
|
|
||||||
|
This is the biggest visual overhaul Codeman has had: a tile grid for driving several sessions at once, a new header, CLI logos everywhere an agent is named, lineage trees, a calmer welcome screen and optional new tab layouts. The defaults are chosen so an upgrade looks familiar: the tab strip stays Classic, the header gets the Compact stats, and the Tiles button is there to try.
|
||||||
|
|
||||||
|
**The tile grid: up to six live sessions side by side.** Click **Tiles** in the header (or press `Ctrl+Shift+G`) and the window splits into a grid of real terminals, each one a full session you can read and type in, with its own connection. Every tile has a small header naming the session, the agent (its logo) and the model it runs (`Claude on Opus 5.5`, `DeepSeek on qwen3.8-27b`, following an in-session `/model` switch), a status dot that turns red and pulses the tile's border when that session needs you, a session menu, zoom (`⤢` or `Alt+Shift+Enter`) and remove (`×`, the session keeps running). Right-click the button for a 2 / 4 / 6 count menu (remembered per device; a hover card explains both clicks). Add sessions by `Ctrl`/`Cmd`+clicking a tab, dragging a tab onto a tile or an empty cell, "Open group as tiles" in a tab group's menu, or just Run while the grid is open. Rearrange by dragging a tile by its header (onto another tile they swap, and an empty cell can sit anywhere), with `Ctrl+Shift+Arrows`, and resize with the column and row dividers; `Alt+Shift+Arrows`, `Ctrl+Tab` and `Alt+[` / `]` move the focus, and the focused tile is the session every panel follows (files, git status, respawn, subagent windows, voice, image paste). Picking a tab that is not tiled shows it on its own and one click brings the grid back; app-driven selections never collapse it. The grid survives a reload (per device, session ids only), opens and closes with a short animation (off under reduced motion), auto-zooms the focused tile when the window gets too small, shows an Attach overlay for a session that is not attached or whose agent exited, and is fully translated into 简体中文. It was built to stay fast with six busy agents: tiles paint first and load their terminals one per frame, a tile replays its history at xterm's own pace and never reads more scrollback than it keeps, the main terminal is parked (no SSE terminal stream) while tiles own the screen, and a window resize refits each tile exactly once. Desktop only (a window at least 1180px wide); the setting is App Settings → Header & Panels → Tiles, on by default on desktop and opt-in on touch tablets. The user guide is the new [Tile Grid](https://github.com/Ark0N/Codeman/wiki/Tile-Grid) wiki page.
|
||||||
|
|
||||||
|
**Split view, rebuilt on the same terminal (#560).** The split's second pane is now a `TerminalTile`, the same component every tile is: its input goes through the exactly-once queue, so a dropped link can no longer lose or double a keystroke; it reconnects on its own after a drop or a server restart; it and its PTY never disagree about size; file paths are clickable and `Ctrl+V` pastes images into it; and app shortcuts, voice and image paste follow the pane you are in. Both panes now name their agent and model above them.
|
||||||
|
|
||||||
|
**A new header.** The header stats come in three styles (App Settings → Header & Panels → Header Stats Style, per device): **Compact**, the new default, draws WS, CPU, MEM and the plan usage windows as two pills where every reading is a ring, a label and a value; **Tiles** gives each one its own box; **As before** keeps the old readout. The icon buttons beside them take the matching shape, and hover now moves the icon, never the button.
|
||||||
|
|
||||||
|
**CLI logos everywhere an agent is named (#532).** The Run menus (toolbar, phone overview picker, custom endpoints, model picker), every agent tab (header strip, rail, sidebar, phone chips, the desktop home rail, Claude's tabs included), the tile and split headers and the welcome screen now show each CLI's own logo instead of a colour dot or a two-letter pill. The marks are inline SVG, follow every skin, and a CLI you added through `clis.json` gets a plain dot.
|
||||||
|
|
||||||
|
**Lineage trees (#544).** The lines from a tab to the tabs it spawned are now one rounded tree per spawning tab, routed through the gaps between tab rows so they never cross a tab or reach the terminal. Every family is always drawn, the selected tab's family is drawn thicker and on top, and a dashed branch now means that child is working.
|
||||||
|
|
||||||
|
**Tab layouts (#538, optional).** App Settings → Appearance → Tabs → **Tab Layout** adds three opt-in arrangements next to the default **Classic** strip: **By state** (a row each for Needs you, Waiting, Working and the rest, most urgent first, flippable with State Order), **By case** (one labelled box per case) and **Ledger** (an aligned grid of equal cells). By state and By case also group the vertical rail and the sidebar. Per device; phones keep their scrolling chip row.
|
||||||
|
|
||||||
|
**A calmer welcome screen.** One primary launcher for the first agent in your catalog (Claude Code on a stock install, the next agent if it is disabled) with its real logo, every other CLI as a slim pill under it, and Cloudflare Tunnel as a quiet link above its QR. The toolbar's "+" and case gear moved into the case picker as "New or link a case…" and "Case settings…" rows, and the duplicate instance stepper is gone (#428).
|
||||||
|
|
||||||
|
**Every session knows its model.** Sessions publish the model they run (`displayModel` on the session state): a custom endpoint's model id, else what the running CLI itself reports (Claude's statusLine, codex's, pi's and opencode's footers, the dsh status line), else the model its config pins (a DeepSeek route) or the one it was launched with. It is persisted, follows `/model` switches, and is stripped of control characters and capped.
|
||||||
|
|
||||||
|
**Idle detection for every agent.** OpenCode, Gemini, Pi and OMP turns now end: their composer bars and spinners are read so a session goes idle when the agent is done instead of spinning "working" forever, codex 0.162's new footer row no longer hides its model or its background-terminal row, and a freshly started or re-attached agent pane now announces its idle to open pages (it used to stay `busy` until a reload). A pane prompted within its first seconds is never settled idle at launch, so send-and-wait cannot resolve before the turn starts.
|
||||||
|
|
||||||
|
**Spreadsheets in the File Viewer (#502).** `.xlsx` files open as a read-only grid with sheet tabs, number formats, merged cells and colours, from the Files panel, attachments or a path an agent prints. They are parsed entirely in your browser in a worker (up to 10 MB) behind admission limits on everything the parser would expand. `.xls` and `.ods` stay download-only, and `file-content` now reports `type: 'spreadsheet'` for `.xlsx`.
|
||||||
|
|
||||||
|
**Default Codex model and reasoning effort (#546).** App Settings has a Default Codex model and a Default Codex reasoning effort, applied to new local Codex sessions (Run menu, Resume and the HTTP API) unless the launch names its own; custom endpoints, Docker and remote sessions keep their own settings.
|
||||||
|
|
||||||
|
**Pop-out windows for native wrapper apps (#432).** A native wrapper (for example an Android app on a foldable) can pop a session, a file preview or a web tab out into a window of its own beside the dashboard through an opt-in, experimental `window.CodemanHost` bridge. Browsers behave exactly as before.
|
||||||
|
|
||||||
|
**Git status for big folders and slow shares (#543).** Two per-device settings under Settings → Bottom bar set how many repositories it lists (up to 50, default 12) and how long one git command may take (5 to 120 s, now 30 s by default), and a repository git cannot read is listed with the reason and counted as `? N` in the indicator instead of silently disappearing.
|
||||||
|
|
||||||
|
**Behaviour change: `Ctrl+W` no longer closes a session.** It is delete-word in every shell and agent CLI, and muscle memory used to kill a session (its pane and CLI, with no confirm) mid-sentence. Close Session has no default key now; bind one in App Settings → Shortcuts if you want it.
|
||||||
|
|
||||||
|
**Removed: the tunnel Upload URL page (#551).** Since the response envelope change it reported "Saved: undefined" and listed nothing. Use the in-app image paste instead. `POST /api/screenshots`, `GET /api/screenshots` and `GET /api/screenshots/:name` (and their `/api/v1` aliases) still work unchanged but log a one-time deprecation warning and will be removed in a future major release.
|
||||||
|
|
||||||
|
**Fixes.** Android keyboards that autocorrect as you type (SwiftKey, Gboard) no longer duplicate the line in the prompt: the corrected word reaches the session exactly once, including when Enter arrives in the same keyboard transaction (#541). opencode tabs: a drag selects text again (so copy-on-select works and `Ctrl+C` copies instead of closing opencode), and the wheel and touch swipes page through opencode's conversation (#555). The Run dropdown no longer runs off the top of the screen: with many CLIs, endpoints and saved URLs it fits between the header and the toolbar, follows the on-screen keyboard and the iPhone safe areas, and scrolls (#542). Claude sessions show the Respawn and Ralph / Todo tabs and the auto-resume toggle in Session Options again, hidden since 1.33.0 (#550, fixes #549). The terminal refits when only its box changes size (a header that grows with the state rows or the lineage gutter used to leave the bottom rows clipped behind the toolbar until a tab switch). A device with any shortcut override no longer gets every App Settings save rejected while the toast still said "Settings saved". Image paste and dictation land in the session they started in. App Settings search finds Split and Tiles by what they do. The Run button family, the Help modal, the shortcut overlay leftovers and the exited-agent tab badge are translated into Chinese.
|
||||||
|
|
||||||
|
**For contributors.** The server reports the port it actually bound (`boundPort`), the tests that shared fixed ports bind ephemeral ones, and a static guard keeps new fixed ports out, so two test runs on one machine no longer collide (#556). A nightly (and on-demand) Playwright browser-suite workflow runs the suites the CI gate cannot, informational and never a gate (#540). The wiki's Contributing page says `npm test` is the CI gate (#552).
|
||||||
|
|
||||||
|
**A final review before shipping.** A last adversarially verified review of the whole release fixed these in the tile view: an image dropped on a tile uploads to that tile's session instead of navigating the browser away; a Claude session started into the grid keeps its welcome banner and transcript; a tile refresh never blanks the screen while it waits; a remote close or a reconnect no longer moves your keyboard into another session's tile; dictation and the phone keyboard's Path and Clear keys reach the focused tile or split pane; each tile caps its live-output backlog and recovers dropped output with one bounded refresh; Redraw on a tile forces the resize it reports; popping out the last tile leaves no frozen view; and "Open group as tiles" no longer pulls an open split into the grid. Also from that review: the By case layout stays one scrolling row on 600 to 767px tablets, the needs-you pulse animates opacity only, a codex session on the ultra effort shows its model, codex's launch defaults are CLI registry data instead of an id check, `npm run build` checks its dependencies before it deletes anything, and the release's new strings (case picker rows, Git status settings, toasts, tile and spreadsheet texts, the Redraw toasts) have zh-CN translations.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Tiles and the split's Pane B got the same two fixes the main terminal got from contributors: opencode's wheel paging and click reports (#555), and the Android keyboard handling that stops autocorrect duplicating a line (#541). Android: a word composed right before Enter in the same keyboard transaction was sent twice; it now arrives once (#541). Codex: App Settings refuses a Default Codex model the server would reject instead of failing the whole save behind a "Settings saved" toast, and the two Codex rows stack under their labels on phones (#546). Run dropdown: its height follows the on-screen keyboard and both iPhone safe areas, a long custom-endpoint label no longer adds a horizontal scrollbar, and the open menu sits above the keyboard accessory bar (#542). Git status: a lone repository git cannot read is no longer shown as clean and empty, a repeated `timeout` query parameter no longer answers 500, and the timeout field accepts any whole number of seconds (#543). Spreadsheets: cells clipped to nothing at the edge of a very large sheet no longer grow its scroll area, and the preview's fixed texts have zh-CN translations (#502). Pop-out windows: the bridge refuses to pop out without a window channel (the tab could never re-dock), the tab menu follows the host-aware default, and Close window works inside a host window (#432). Every deprecated `/api/screenshots` route now logs its warning, pinned per route (#551). Across the new tab layouts and header styles: lineage lines run between the state labels and the tabs and never through a case box, an open Tiles or Split button stays highlighted in the boxed header styles, a phone keeps the active chip in view when it changes state band, and the Tab Layout and Header Stats Style settings are translated.
|
||||||
|
|
||||||
## 1.35.0
|
## 1.35.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -19,16 +19,12 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||||
@@ -54,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
|
|||||||
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -385,6 +381,14 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
|||||||
|
|
||||||
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
||||||
|
|
||||||
|
### Tile Grid
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session; the grid is remembered for next time. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
|
||||||
|
|
||||||
### Persistent Sessions
|
### Persistent Sessions
|
||||||
|
|
||||||
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
||||||
@@ -418,7 +422,7 @@ The title is templated into the served HTML on first byte, so it's correct from
|
|||||||
### Tab Alerts
|
### Tab Alerts
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="Tab states, annotated: a working tab with a spinning green ring, a red tab blocked on the agent's question shown below it, and a yellow tab whose turn is done, both alert tabs breathing" width="900"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||||
@@ -731,6 +735,10 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
|
||||||
#### Step 1: install it
|
#### Step 1: install it
|
||||||
|
|
||||||
| How | Command | Scope |
|
| How | Command | Scope |
|
||||||
@@ -936,6 +944,24 @@ codeman tui --list # numbered session list (plain tex
|
|||||||
codeman tui 3 # attach to session 3 of that list
|
codeman tui 3 # attach to session 3 of that list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `codeman agent` — session-to-session verbs in every CLI mode
|
||||||
|
|
||||||
|
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
### Hooks (events flowing _back_ to Codeman)
|
### Hooks (events flowing _back_ to Codeman)
|
||||||
|
|
||||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||||
|
|||||||
@@ -24,11 +24,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
|
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||||
@@ -386,6 +382,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
|||||||
|
|
||||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
||||||
|
|
||||||
|
### 平铺网格
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话,网格会被记住,下次直接恢复。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
|
||||||
|
|
||||||
### 持久化会话
|
### 持久化会话
|
||||||
|
|
||||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/tab-rail-resize.browser.test.ts',
|
'test/tab-rail-resize.browser.test.ts',
|
||||||
'test/tab-activation.browser.test.ts',
|
'test/tab-activation.browser.test.ts',
|
||||||
'test/tab-layout-editing.browser.test.ts',
|
'test/tab-layout-editing.browser.test.ts',
|
||||||
|
'test/tab-rail-search.browser.test.ts',
|
||||||
'test/session-sidebar-ux.browser.test.ts',
|
'test/session-sidebar-ux.browser.test.ts',
|
||||||
'test/session-options-responsive.browser.test.ts',
|
'test/session-options-responsive.browser.test.ts',
|
||||||
'test/inline-rename.test.ts',
|
'test/inline-rename.test.ts',
|
||||||
@@ -33,6 +34,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/capture-geometry-retry.browser.test.ts',
|
'test/capture-geometry-retry.browser.test.ts',
|
||||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||||
'test/split-pane-terminal.browser.test.ts',
|
'test/split-pane-terminal.browser.test.ts',
|
||||||
|
'test/terminal-tile-scroll.browser.test.ts',
|
||||||
'test/shift-enter-keypress.browser.test.ts',
|
'test/shift-enter-keypress.browser.test.ts',
|
||||||
'test/key-tester.browser.test.ts',
|
'test/key-tester.browser.test.ts',
|
||||||
'test/webhook-settings.browser.test.ts',
|
'test/webhook-settings.browser.test.ts',
|
||||||
@@ -41,7 +43,10 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/git-status.browser.test.ts',
|
'test/git-status.browser.test.ts',
|
||||||
'test/split-pane-orchestration.browser.test.ts',
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
'test/split-pane-auto-collapse.browser.test.ts',
|
'test/split-pane-auto-collapse.browser.test.ts',
|
||||||
|
'test/spreadsheet-preview.browser.test.ts',
|
||||||
'test/mobile-ime-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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ payload return `{ "success": true, "data": {} }`.
|
|||||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
> `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
|
||||||
|
> use. They are removed in a later MAJOR, after at least one MINOR release that
|
||||||
|
> carries this warning (see `docs/versioning-policy.md`). To hand
|
||||||
|
> a file to an agent, use `POST /api/sessions/:id/paste-image`, which saves it into
|
||||||
|
> that session's workspace.
|
||||||
|
|
||||||
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
||||||
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
||||||
@@ -445,6 +452,41 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
|
|||||||
slot, because the routes release the waiter when the client disconnects, but a
|
slot, because the routes release the waiter when the client disconnects, but a
|
||||||
client that opens many concurrent waits against one session will still hit the cap.
|
client that opens many concurrent waits against one session will still hit the cap.
|
||||||
|
|
||||||
|
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
|
||||||
|
|
||||||
|
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||||
|
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||||
|
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||||
|
geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||||
|
`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. |
|
||||||
|
|
||||||
|
## The `codeman agent` CLI (client over these endpoints)
|
||||||
|
|
||||||
|
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
## Session lineage (`parentSessionId`)
|
## Session lineage (`parentSessionId`)
|
||||||
|
|
||||||
A create request may name the session that spawned it, which the web UI draws as a
|
A create request may name the session that spawned it, which the web UI draws as a
|
||||||
@@ -470,6 +512,32 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
|||||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||||
unresolved) and survives a server restart.
|
unresolved) and survives a server restart.
|
||||||
|
|
||||||
|
## Session model (`displayModel`)
|
||||||
|
|
||||||
|
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||||
|
session runs as far as the server knows it, for the web UI's session headers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"displayModel": { "model": "qwen3.8-27b", "source": "screen" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`source` is where it came from, strongest first:
|
||||||
|
|
||||||
|
| `source` | Meaning |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
|
||||||
|
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
|
||||||
|
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
|
||||||
|
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
|
||||||
|
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
|
||||||
|
|
||||||
|
Between `statusline` and `screen` the newest report wins. The field is absent when no
|
||||||
|
model is known (a shell, a CLI that reports none and was launched without one). `model`
|
||||||
|
is display text from a pane or a CLI report: control characters are stripped and it is at
|
||||||
|
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
|
||||||
|
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.
|
||||||
|
|
||||||
## Approvals Inbox
|
## Approvals Inbox
|
||||||
|
|
||||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||||
@@ -724,11 +792,13 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
|
|||||||
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
||||||
|
|
||||||
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
||||||
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
||||||
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
||||||
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||||
|
|
||||||
`data` is `{ state, repos, reposTruncated, checkedAt }`:
|
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
|
||||||
|
|
||||||
|
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only):
|
||||||
|
|
||||||
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
||||||
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
||||||
@@ -763,10 +833,10 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
|
|||||||
Result (`data`):
|
Result (`data`):
|
||||||
|
|
||||||
- `applied` — `false` for the dry run.
|
- `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).
|
- `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.
|
- `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.
|
- `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`).
|
- `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).
|
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
|
||||||
|
|||||||
@@ -71,17 +71,23 @@ We tested three browser automation frameworks against the Codeman web UI:
|
|||||||
|
|
||||||
## Test File Structure
|
## Test File Structure
|
||||||
|
|
||||||
### Port Allocation
|
### Ports
|
||||||
|
|
||||||
| Port Range | Test File |
|
A test that starts a server binds an ephemeral port, never a fixed one:
|
||||||
|------------|-----------|
|
|
||||||
| 3150-3153 | browser-e2e.test.ts (existing) |
|
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
|
||||||
| 3154 | file-link-click.test.ts |
|
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
|
||||||
| 3155 | browser-playwright.test.ts |
|
any `WebServer` built under `test/` on a non-zero port.
|
||||||
| 3156 | browser-puppeteer.test.ts |
|
- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
|
||||||
| 3157 | browser-agent.test.ts |
|
`address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
|
||||||
| 3158-3160 | browser-comparison.test.ts |
|
- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
|
||||||
| 3180-3182 | scripts/browser-comparison.mjs |
|
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
|
||||||
|
fixed ports 3180-3182.
|
||||||
|
|
||||||
### File Purposes
|
### File Purposes
|
||||||
|
|
||||||
@@ -106,7 +112,7 @@ const browser = await chromium.launch({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('http://localhost:3000');
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
// Auto-waiting selectors
|
// Auto-waiting selectors
|
||||||
await page.click('.btn-claude');
|
await page.click('.btn-claude');
|
||||||
@@ -140,7 +146,7 @@ const browser = await puppeteer.launch({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('http://localhost:3000');
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
// Manual waiting often needed
|
// Manual waiting often needed
|
||||||
await page.click('.btn-claude');
|
await page.click('.btn-claude');
|
||||||
@@ -186,7 +192,7 @@ function agentBrowserJson<T>(cmd: string): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
agentBrowser('open http://localhost:3000');
|
agentBrowser(`open ${BASE_URL}`);
|
||||||
agentBrowser('click ".btn-claude"');
|
agentBrowser('click ".btn-claude"');
|
||||||
const title = agentBrowserJson<{title: string}>('get title');
|
const title = agentBrowserJson<{title: string}>('get title');
|
||||||
|
|
||||||
@@ -246,11 +252,14 @@ npx playwright install chromium
|
|||||||
### 4. Wait for Server Startup
|
### 4. Wait for Server Startup
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const server = new WebServer(PORT);
|
const server = new WebServer(0, false, true); // port 0 (the OS picks one), no TLS, testMode
|
||||||
await server.start();
|
await server.start();
|
||||||
await new Promise(r => setTimeout(r, 1000)); // Allow server to stabilize
|
const BASE_URL = `http://localhost:${server.boundPort}`;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`boundPort` holds the real port only once `start()` has resolved. The `BASE_URL` used by the
|
||||||
|
other snippets on this page is this one.
|
||||||
|
|
||||||
### 5. Clean Up Sessions
|
### 5. Clean Up Sessions
|
||||||
|
|
||||||
Track created sessions for cleanup:
|
Track created sessions for cleanup:
|
||||||
|
|||||||
@@ -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:
|
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.
|
- 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.
|
- 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*.
|
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 {
|
interface CliEntry {
|
||||||
id: CliId; // 'codex'
|
id: CliId; // 'codex'
|
||||||
label: string; // 'Codex' — shown in menus
|
label: string; // 'Codex' — shown in menus
|
||||||
shortBadge: string; // tab badge, e.g. 'CX'
|
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
|
accent: string; // single hex colour
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||||
@@ -49,6 +49,11 @@ interface CliEntry {
|
|||||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
||||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
// — how this CLI's pane shows work, work it started in the background, and a turn
|
||||||
// that ended waiting for workers it will resume from
|
// that ended waiting for workers it will resume from
|
||||||
|
// .modelDetect?: { screenLine, screenLines? }
|
||||||
|
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||||
|
// .launchDefaults?: { [launchParam]: settingsKey }
|
||||||
|
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
|
||||||
|
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
|
||||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -57,10 +62,14 @@ interface CliEntry {
|
|||||||
|
|
||||||
### Regexes that come from config
|
### Regexes that come from config
|
||||||
|
|
||||||
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||||
|
|
||||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||||
|
|
||||||
|
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 8, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), codex's ` <model> <effort> · ` footer (its last row, or the row above codex 0.162's indented hint row, so a two-row window), and opencode's composer agent row (`┃ Build <model> <provider>`, directly above the box's `╹` edge, eight rows because its home screen puts up to five rows of its own chrome below it). opencode's field is the model AND the provider, since only colour separates them on that row; the pattern takes the LAST such row in the window, so a composer-shaped row the agent prints higher up cannot stand in for it. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||||
|
|
||||||
|
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
|
||||||
|
|
||||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||||
@@ -75,10 +84,11 @@ Two CLIs declare such a row today, and they put it in different places. Claude w
|
|||||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||||
the `·` its footer joins items with. Codex pins
|
the `·` its footer joins items with. Codex pins
|
||||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||||
puts the row third from the bottom once the status line and the composer are counted, so its
|
puts the row third from the bottom once the status line and the composer are counted, and
|
||||||
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
fourth on codex 0.162+ at rest, where a `← for agents · ? for shortcuts` hint row sits under
|
||||||
against live panes rather than read out of a binary, which is the standard for adding a
|
the status line (it disappears while a prompt is typed). So its entry declares
|
||||||
third.
|
`watchingLines: 4` and matches that row end to end. Both were measured against live panes
|
||||||
|
rather than read out of a binary, which is the standard for adding a third.
|
||||||
|
|
||||||
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
||||||
to wait for workers the CLI will resume from by itself. When background agents or an
|
to wait for workers the CLI will resume from by itself. When background agents or an
|
||||||
@@ -255,9 +265,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
|||||||
|
|
||||||
## MCP server sync
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,18 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
|||||||
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||||
profile. That is also how you point dsh at a local or third-party provider.
|
profile. That is also how you point dsh at a local or third-party provider.
|
||||||
|
|
||||||
|
Codeman does READ the route, for display only: a session header names the model
|
||||||
|
the TUI's status line draws, and while it draws none (the status bar's model
|
||||||
|
field switched off, or not painted yet) the model the session's route config
|
||||||
|
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
|
||||||
|
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
|
||||||
|
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
|
||||||
|
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
|
||||||
|
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
|
||||||
|
The reader never writes, follows no symlink out of the dsh home, and returns the
|
||||||
|
model id alone. The TUI can still reject a pinned route against its provider's
|
||||||
|
model catalog at startup; the status line, when on, then shows what it chose.
|
||||||
|
|
||||||
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||||
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||||
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 706 KiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -158,7 +158,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
|||||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||||
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
|
|||||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||||
|
|
||||||
|
|||||||
@@ -163,9 +163,13 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
- **No idle/completion hook.** Idle detection reads the screen instead: the
|
||||||
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
|
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
||||||
POSTing to `/api/hook-event` would be the highest-value follow-up.
|
the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`)
|
||||||
|
or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11.
|
||||||
|
Without it an omp session that had started a turn never left `busy`. If omp ever
|
||||||
|
ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be
|
||||||
|
the highest-value follow-up.
|
||||||
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
||||||
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
||||||
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||||
|
|||||||
@@ -920,6 +920,8 @@ const mode = cmd.includes('opencode') ? 'opencode' : 'claude';
|
|||||||
|
|
||||||
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
|
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
|
||||||
|
|
||||||
|
> **Update 2026-10-09 (working/idle shipped, from measured data):** the registry entry now declares `capabilities.workDetect` for opencode, measured on a live opencode 1.3.0 pane (pane captures every 250-300 ms through real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream). Every composer row starts with a `┃` bar, which arms the shared screen-probed idle check; a running turn puts an 8-cell knight-rider spinner (`⬝■■■■■■⬝ esc interrupt`) at the head of the footer row, redrawn about every 40 ms, and `[⬝■]{8}` is the working line. The label is not the anchor: tmux ships `esc` and `interrupt` as separate words joined by cursor moves, and below about 45 columns the footer wraps it. At rest the TUI is silent (no cursor or timer redraws), and a pending permission prompt replaces the composer and stops the spinner, so it reads as idle. Before this, a turn that ran a tool latched the session `busy` for good (the tool row's braille spinner tripped the generic spinner detector, and nothing ever armed the idle check). Token parsing and the ANSI content filter remain deferred.
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
Detect OpenCode's state from terminal output (idle, working, ready).
|
Detect OpenCode's state from terminal output (idle, working, ready).
|
||||||
|
|
||||||
|
|||||||
@@ -223,9 +223,13 @@ command override instead.
|
|||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
|
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
|
||||||
idle detection falls back to output-stabilization like the other external CLIs.
|
idle detection reads the screen: the registry entry's `workDetect` names pi's
|
||||||
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
|
composer rule (`─`) as the glyph that arms the idle check and the spinner pi embeds
|
||||||
signal; a Codeman pi extension using it is the highest-value follow-up.
|
in that rule while a turn runs (`── ⠏ Working ───`) as the working line, measured
|
||||||
|
on pi 1.1.0. Without it a pi session that had started a turn never left `busy`,
|
||||||
|
since pi never draws Claude's `❯`. Pi 0.84.0 shipped an `agent_settled` extension
|
||||||
|
event that is a genuine idle signal; a Codeman pi extension using it is still the
|
||||||
|
highest-value follow-up.
|
||||||
- **No response viewer.** Pi writes JSONL v3 session files under
|
- **No response viewer.** Pi writes JSONL v3 session files under
|
||||||
`~/.pi/agent/sessions/`; nothing reads them yet.
|
`~/.pi/agent/sessions/`; nothing reads them yet.
|
||||||
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
|
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
|
||||||
|
|||||||
@@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal
|
|||||||
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
|
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
|
||||||
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
|
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
|
||||||
- 40ms SGR coalescing: never send per-event writes to the server.
|
- 40ms SGR coalescing: never send per-event writes to the server.
|
||||||
- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three.
|
- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`.
|
||||||
- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
|
- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified.
|
||||||
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
||||||
|
|
||||||
## Testing (per repo rules)
|
## Testing (per repo rules)
|
||||||
|
|||||||
@@ -354,8 +354,9 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c
|
|||||||
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||||
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||||
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||||
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
|
||||||
realpath containment.
|
`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and
|
||||||
|
other text), not realpath containment.
|
||||||
|
|
||||||
Two registration paths, with **different trust**:
|
Two registration paths, with **different trust**:
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,15 @@ view needs a wide viewport). So:
|
|||||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||||
instance with no overlay is exactly how Codeman behaved before the local-
|
instance with no overlay is exactly how Codeman behaved before the local-
|
||||||
echo overlay existed for touch devices — normal, not degraded, for a
|
echo overlay existed for touch devices — normal, not degraded, for a
|
||||||
keyboard-and-mouse user.
|
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
|
||||||
|
which has gained three pieces of the primary pane: hollow-buffer wheel
|
||||||
|
paging (#555) and the desktop click report for a CLI with
|
||||||
|
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
||||||
|
tile, and its own keyCode-229 soft-keyboard controller
|
||||||
|
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
|
||||||
|
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
|
||||||
|
width gate is all that keeps a phone out, and a wide Android tablet clears
|
||||||
|
it. See that file's fileoverview.)
|
||||||
|
|
||||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||||
parity is a scoped v2 (extract the shared logic already once you have two
|
parity is a scoped v2 (extract the shared logic already once you have two
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent
|
|||||||
|
|
||||||
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
||||||
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
||||||
|
⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged.
|
||||||
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
||||||
|
|
||||||
### 2.6 Helpers that already exist and should be reused
|
### 2.6 Helpers that already exist and should be reused
|
||||||
@@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive
|
|||||||
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
||||||
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
||||||
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
||||||
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||||
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
||||||
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30
|
|||||||
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
||||||
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
||||||
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
||||||
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
|
6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16).
|
||||||
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
||||||
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,205 @@
|
|||||||
# Tile Grid: Design Spec
|
# Tile Grid: Design Spec
|
||||||
|
|
||||||
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`, local only; PR 2 (the grid) proposed. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
|
**Status**: Merged for the 1.40.0 release as #560 (the TerminalTile foundation) and #561 (the grid). Where the "As built" section below differs from this spec, As built is authoritative. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
|
||||||
**Author**: Claude (planning session with the maintainer), 2026-10-06
|
**Author**: Claude (planning session with the maintainer), 2026-10-06
|
||||||
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktree `claudeman-tiles`)
|
**Branches**: developed as PR 1 `feat/terminal-tile` and PR 2 `feat/tile-grid` stacked on it, both merged
|
||||||
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
|
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
|
||||||
|
|
||||||
|
## As built: where PR 2 differs from this spec
|
||||||
|
|
||||||
|
The design below stands; these are the places the built grid deliberately went another way,
|
||||||
|
or settled a question the spec left open. The invariants as built are in
|
||||||
|
`docs/architecture-invariants.md#tile-grid`.
|
||||||
|
|
||||||
|
- **An agent that exited in a live pane (`paneExit`) gets no Attach button.** Both attach
|
||||||
|
routes (`/interactive`, `/shell`) refuse while the pane's tmux client still runs ("Session
|
||||||
|
already has a running process") and report that in the envelope of a 200, so the
|
||||||
|
edge-case row below cannot work without a server change. The tile shows the exit and
|
||||||
|
points at Close session. A session with no PTY (`pid === null`) and a socket closed with
|
||||||
|
4009 do get Attach. Restarting an exited agent in place is a follow-up.
|
||||||
|
- **`Ctrl+Shift+G` follows `showTileGridButton`** (decided by the owner, decision 6): with
|
||||||
|
the setting off the toggle chord is inert. A grid opened another
|
||||||
|
way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords.
|
||||||
|
- **The Tiles button ships ON** (owner, 1.36.0 beta): `showTileGridButton` defaults to on
|
||||||
|
everywhere but handhelds (their defaults object keeps it off) and, since the 1.40.0 final
|
||||||
|
checkup, devices whose primary pointer is coarse (touch tablets, opt-in there), so the
|
||||||
|
chord is live by default too. An absent key resolves through the device defaults in both the button
|
||||||
|
(settings-ui.js) and the chord (`tileShortcutFor`), so they cannot disagree.
|
||||||
|
- **Dividers are grid tracks.** Each gap between columns and rows is its own 6px track (the
|
||||||
|
grid gap is 0) and every tile and every empty slot is placed explicitly in its cell
|
||||||
|
(`grid.cells`, see "Tiles move"). Fractions reset when the column or row count changes.
|
||||||
|
- **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom
|
||||||
|
(window too small for the minimum tile) follows focus instead.
|
||||||
|
- **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane
|
||||||
|
B too), and a refresh fetches at its turn in the queue: the tile keeps its last frame
|
||||||
|
through its wait and its own round trip, and is reset with the queued in-stream `\x1bc`
|
||||||
|
only once the capture is in hand, right before the replay (never xterm's `clear()` before
|
||||||
|
the fetch). A failed, aborted or empty fetch writes nothing and resets nothing: the tile
|
||||||
|
keeps its last frame and every held live frame.
|
||||||
|
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
|
||||||
|
- **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the
|
||||||
|
first build's 12px glyphs read as tiny next to the name), the size of the app header's own
|
||||||
|
icon buttons; the header grew from 24 to 28px to hold them.
|
||||||
|
- **The grid is translated for 简体中文 (zh-CN)** (owner request): 平铺 for the feature, 窗格 for
|
||||||
|
one tile, key names untranslated, mouse actions in the Help modal's key column (`Click`,
|
||||||
|
`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 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),
|
||||||
|
"Right-click: choose 2, 4 or 6 tiles", what opens when the count does not fit the window,
|
||||||
|
and Shift+F10 when the keyboard brought it. It shows 300 ms after a hovering pointer rests
|
||||||
|
on the button or after a `:focus-visible` focus, never for touch or a device without
|
||||||
|
hover, never with the count menu open; it hides on leave, blur, Escape, scroll, resize and
|
||||||
|
any press, click or right-click on the button (capture phase, so the menu never opens
|
||||||
|
beside it). The card always exists, hidden and current, as the button's
|
||||||
|
`aria-describedby`.
|
||||||
|
- **Right-click on Tiles is a 2 / 4 / 6 count menu** (owner decision 10; the session
|
||||||
|
picker is gone). Three counts with their shapes (the grid's own 2x1, 2x2, 3x2 drawn as
|
||||||
|
cells), the remembered one checked. A count the window cannot fit is greyed out with the
|
||||||
|
reason ("This window fits N tiles"); a remembered count that does not fit stays checked
|
||||||
|
but greyed, the keyboard starts on the largest that fits, and a click opens what fits.
|
||||||
|
Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move
|
||||||
|
over the counts that fit, Enter or Space picks, Escape closes it alone (the global
|
||||||
|
Escape handler gives it the key first, like the tab-group menu) and puts the keyboard
|
||||||
|
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` 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.
|
||||||
|
- **Header icons move their icon on hover, never the button** (owner request: the Tiles
|
||||||
|
and folder buttons "weirdly turn" on hover; make a nicer hover). A global
|
||||||
|
`.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the settings gear)
|
||||||
|
turned every header icon button, swinging its hover background into a diamond. Now
|
||||||
|
only the gear's ICON turns (45 degrees, one tooth), the Tiles button's four squares
|
||||||
|
spread apart, and the folder cross-fades to an open folder (`.icon-folder-closed` /
|
||||||
|
`.icon-folder-open` in its SVG); every other icon just takes the hover colour. Pointer
|
||||||
|
devices only (`@media (hover: hover)`), transitions off under reduced motion. Pinned by
|
||||||
|
`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 `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
|
||||||
|
load queue reports its first capture done, then fades in whole (160 ms), so no replay
|
||||||
|
scrolls by. Closing with the toggle (button, Ctrl+Shift+G; owner answer: only these), a
|
||||||
|
still copy of the tiles (`_ghostTileGrid`: clones, no xterm, socket or listener; inert,
|
||||||
|
`aria-hidden`, no pointer) dims at once over the stage (so the click is answered) and
|
||||||
|
stays until the single view's `selectSession` has replayed its session, at most 700 ms,
|
||||||
|
then fades: no empty single view between the two. A re-form fades the old grid's copy at
|
||||||
|
once. The count menu fades in (140 ms). Every one animates opacity and transform only, so
|
||||||
|
FitAddon measures the final cell and each tile still sends one PTY resize; under
|
||||||
|
`prefers-reduced-motion` nothing moves and no copy is made. A web tab hides the copy.
|
||||||
|
- **Opening paints the frames first** (owner answer: "paced connect: in"). The six
|
||||||
|
terminals used to be built inside the click (about 100 of its 140 ms before the first
|
||||||
|
frame). `_connectTilesPaced` builds one per animation frame, the focused tile's first,
|
||||||
|
so the click paints its empty tiles in about 20 ms and the entrance plays while they are
|
||||||
|
built. The time until all tiles have painted is unchanged: the load queue serves one
|
||||||
|
capture at a time, so only the focused tile's connect is on its path, one frame later.
|
||||||
|
`openTileGrid` therefore returns before the terminals exist: a selection that focuses a
|
||||||
|
tile whose terminal is not built yet hands the keyboard over in `_connectTile`
|
||||||
|
(`focusOnConnect`), never when `focus: false` was asked.
|
||||||
|
- **The grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is
|
||||||
|
the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable,
|
||||||
|
so going back to nine is that one line. A stored grid with more ids comes back as its first
|
||||||
|
six. Where this spec says nine, read six.
|
||||||
|
- **Each header names the harness and the model** (owner request): the tile header is
|
||||||
|
`● [logo] name · model ……… ⋯ ⤢ ×`. The logo is PR #532's `run-mode-dot <cliId>` slot
|
||||||
|
(the id is data), the model the session's `displayModel` (custom endpoint, else what the
|
||||||
|
CLI itself reports: claude's statusline, a footer read with `capabilities.modelDetect`
|
||||||
|
for dsh and codex; else what the CLI's config pins, dsh-TUI's route (owner feedback 1:
|
||||||
|
a dsh session with its status bar's model field off shows `qwen3.8-27b (from config)`);
|
||||||
|
else the launch model; else nothing, the logo alone). Both split
|
||||||
|
panes carry the same strip: Pane B's header, and Pane A's while the split is open. Pane
|
||||||
|
B's close is a tile button (26px).
|
||||||
|
- **No + in the tile header** (owner decision 9): the header is `● name ……… ⋯ ⤢ ×`. The
|
||||||
|
+ menu and its "New session in this case" are gone; tiles are added from the Tiles
|
||||||
|
button (and its right-click count menu), Ctrl/Cmd+click on a tab, a dragged tab, "Open
|
||||||
|
group as tiles" and Run joining the open grid. Where this spec describes a `+`, it no
|
||||||
|
longer exists.
|
||||||
|
- **No SSE terminal stream while tiles own the terminal** (performance pass): the filter
|
||||||
|
names a fixed id no session takes (`TILE_GRID_SSE_FILTER`), not `[activeSessionId]` as
|
||||||
|
"Parking the main terminal" below says; the server's filter gates only terminal
|
||||||
|
batches, so lifecycle and hook events are unaffected, and leaving the grid
|
||||||
|
re-subscribes the shown session.
|
||||||
|
- **Tiles move** (owner request: "give me the option to move the tiles around"; not a
|
||||||
|
numbered decision). The grid is CELLS, not a packed list (owner: "the empty tab doesnt
|
||||||
|
always have to be the last one ... it can also be tab nr 4 or 3"): `grid.cells` holds a
|
||||||
|
session id or `null` per cell and is the one source of truth, `grid.ids` the tiles in
|
||||||
|
reading order derived from it. The shape still comes from the tile count (the layout
|
||||||
|
table), the cap counts tiles, never empty cells, and an empty cell can be any cell (in
|
||||||
|
practice one at most: a 3x2 holds 5 or 6 tiles, a 2x2 3 or 4). A tile's header, its free
|
||||||
|
area (not the buttons, not the rename input), drags it: onto another tile the two trade
|
||||||
|
places, onto an empty cell it moves there and leaves its own cell empty, nothing else
|
||||||
|
moving; a tiled session's tab does the same, and a tab of a session not tiled yet joins in
|
||||||
|
the cell it is dropped on. It is a native drag through the tab drop targets (capture
|
||||||
|
phase, stopped before xterm), carrying a type of its own and never text, and it is not
|
||||||
|
`draggedTabId`, so neither a text field nor the tab strip takes it; Escape or a drop
|
||||||
|
anywhere else cancels with nothing changed, focus included: the header focuses its tile on
|
||||||
|
click, not on press (owner's answer: best practice; the body keeps press-to-focus, so
|
||||||
|
focus moves before a press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile
|
||||||
|
Left/Right/Up/Down, registry, rebindable) move the focused tile to the adjacent cell: into
|
||||||
|
it when empty, trading places when a tile is there, never jumping a cell; focus stays on
|
||||||
|
it. Every move goes through `_reorderTiles`: no remount, reconnect or reload; divider
|
||||||
|
sizes belong to the cells, so only a tile whose cell size changed fits (one PTY resize,
|
||||||
|
#464). Removing a tile leaves its cell empty where it was, and adding one takes the first
|
||||||
|
empty cell (or the one a tab was dropped on), while the shape stays; a shape change
|
||||||
|
(`fitTileCells`, constants.js) keeps each tile's row and column when all fit and otherwise
|
||||||
|
packs the tiles in reading order, which differs from plain packing only when a 2x2 grows
|
||||||
|
to a 3x2 (the four tiles stay put). Focus never lands on an empty cell: Alt+Shift+Arrows
|
||||||
|
go along the row past one, or to the nearest row with a tile (the same column, else the
|
||||||
|
nearest), and Ctrl+Tab and Alt+[ / ] cycle the tiles only. `codeman:tile-grid` stays ids
|
||||||
|
only: its `ids` are the cells, `null` for an empty one (a build before cells drops the
|
||||||
|
nulls and reads them packed); a reload brings the holes back when the shape is the same, a
|
||||||
|
session gone by then leaves its cell empty, another shape packs, and the old packed format
|
||||||
|
reads unchanged. A fresh grid (Ctrl/Cmd+click with the grid closed, "Open group as
|
||||||
|
tiles") opens packed; only the toggle and the page-load restore bring holes back (the
|
||||||
|
toggle through `reformTileCells` when the count changes the set). Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so
|
||||||
|
their keys never reach the CLI; a tiled tab dropped on the zoomed tile is refused too, as
|
||||||
|
the owner confirmed) and with a single tile. Both arrow chord families, focus and move,
|
||||||
|
skip a text field, where shifted arrows select (owner's answer: best practice). Default
|
||||||
|
keys: every other two-modifier arrow chord is taken (Ctrl+Alt switches workspaces,
|
||||||
|
Ctrl+Alt+Shift moves a window to another workspace in GNOME, Alt is back/forward,
|
||||||
|
Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by the browsers, GNOME, KDE,
|
||||||
|
macOS and Claude Code, and costs only a terminal editor's word selection inside a tile
|
||||||
|
while the grid is open.
|
||||||
|
- **A tile that joins before its pane exists resends its size when the pid appears**
|
||||||
|
(`TerminalTile.paneStarted()`): the server drops a resize for a session with no PTY and
|
||||||
|
spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a
|
||||||
|
pre-spawn resize at spawn time on the server would make this unnecessary (follow-up).
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codeman's terminal area shows exactly one session at a time. The split pane
|
Codeman's terminal area shows exactly one session at a time. The split pane
|
||||||
@@ -17,7 +212,8 @@ closed and reopened.
|
|||||||
The goal is a dashboard of agents: four, six or nine live Claude sessions on
|
The goal is a dashboard of agents: four, six or nine live Claude sessions on
|
||||||
one monitor, each readable and typeable, with its state visible at a glance.
|
one monitor, each readable and typeable, with its state visible at a glance.
|
||||||
The target picture is a 3x2 grid of tiles, each tile a full terminal with a
|
The target picture is a 3x2 grid of tiles, each tile a full terminal with a
|
||||||
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
|
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×` (as built:
|
||||||
|
no `+`, owner decision 9).
|
||||||
|
|
||||||
## Goal (v1)
|
## Goal (v1)
|
||||||
|
|
||||||
@@ -35,8 +231,10 @@ small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
|
|||||||
|
|
||||||
## Non-goals (v1)
|
## Non-goals (v1)
|
||||||
|
|
||||||
- Phones and tablets. The grid is desktop-only, gated at 1180 px like the
|
- Phones. The grid is gated on width alone at 1180 px like the split
|
||||||
split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`).
|
(`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`); a
|
||||||
|
wide tablet, or a large foldable unfolded in landscape, can reach it (see the
|
||||||
|
keyboard exception below).
|
||||||
- More than 9 tiles.
|
- More than 9 tiles.
|
||||||
- WebGL rendering inside tiles (see "Rendering" below).
|
- WebGL rendering inside tiles (see "Rendering" below).
|
||||||
- Full parity with the main terminal's touch and IME features: local-echo
|
- Full parity with the main terminal's touch and IME features: local-echo
|
||||||
@@ -44,7 +242,11 @@ small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
|
|||||||
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
|
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
|
||||||
history" banner. These exist for touch devices or rare cases; a desktop
|
history" banner. These exist for touch devices or rare cases; a desktop
|
||||||
keyboard user types straight into xterm, which is how Codeman behaved before
|
keyboard user types straight into xterm, which is how Codeman behaved before
|
||||||
those features existed.
|
those features existed. (One exception, since the 1180 px gate is width
|
||||||
|
only and a wide Android tablet clears it: every tile wires the main
|
||||||
|
terminal's keyCode-229 soft-keyboard controller, terminal-keycode229-recovery.js,
|
||||||
|
so an Android autocorrect is sent as an edit rather than a duplicated line,
|
||||||
|
#541, and a character committed with Enter is not lost, #441.)
|
||||||
- Server-side persistence of grids (named presets per owner).
|
- Server-side persistence of grids (named presets per owner).
|
||||||
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
|
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
|
||||||
|
|
||||||
@@ -108,9 +310,10 @@ work also fixes gaps the split pane has today.
|
|||||||
|
|
||||||
### Entry points
|
### Entry points
|
||||||
|
|
||||||
- **Header Tiles button** (its own button, beside Split). Opens a picker with
|
- **Header Tiles button** (its own button, beside Split). As built (decision 8)
|
||||||
checkboxes over open sessions, ordered like the tab strip. When the grid is
|
a click opens the grid at once, the same as the toggle shortcut; right-click
|
||||||
open, the button toggles it closed.
|
is the 2 / 4 / 6 count menu (decision 10; the session picker it replaced is
|
||||||
|
gone). When the grid is open, a click closes it.
|
||||||
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
|
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
|
||||||
closed).
|
closed).
|
||||||
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
|
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
|
||||||
@@ -136,8 +339,8 @@ Automatic by tile count, computed by a pure helper:
|
|||||||
| 7-9 | 3x3 |
|
| 7-9 | 3x3 |
|
||||||
|
|
||||||
Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px,
|
Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px,
|
||||||
roughly 60 columns at the default tile font), so the picker disables additions
|
roughly 60 columns at the default tile font), so the count menu greys out the
|
||||||
the window cannot fit.
|
counts the window cannot fit.
|
||||||
|
|
||||||
Column and row dividers are draggable (generalizing the split divider): the
|
Column and row dividers are draggable (generalizing the split divider): the
|
||||||
grid stores track fractions (`grid-template-columns: <a>fr <b>fr …`), each
|
grid stores track fractions (`grid-template-columns: <a>fr <b>fr …`), each
|
||||||
@@ -146,7 +349,8 @@ animation frame, and sends one resize per affected tile at pointer-up.
|
|||||||
|
|
||||||
### Tile header
|
### Tile header
|
||||||
|
|
||||||
`● name ……… ⋯ ⤢ + ×`
|
`● name ……… ⋯ ⤢ + ×` (as built: `● [logo] name · model ……… ⋯ ⤢ ×`, owner decision 9 and
|
||||||
|
the harness/model request; see "As built")
|
||||||
|
|
||||||
- **●** status dot from the existing six-state classifier
|
- **●** status dot from the existing six-state classifier
|
||||||
(`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`):
|
(`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`):
|
||||||
@@ -165,7 +369,8 @@ animation frame, and sends one resize per affected tile at pointer-up.
|
|||||||
it again (or the shortcut) restores the grid.
|
it again (or the shortcut) restores the grid.
|
||||||
- **+** adds a session: a picker of open sessions not yet tiled, plus "New
|
- **+** adds a session: a picker of open sessions not yet tiled, plus "New
|
||||||
session in this case", which runs the normal quick-start for the tile's case
|
session in this case", which runs the normal quick-start for the tile's case
|
||||||
and drops the result into the next slot.
|
and drops the result into the next slot. (Built, then removed by owner
|
||||||
|
decision 9.)
|
||||||
- **×** removes the tile ONLY. The session keeps running. Killing stays behind
|
- **×** removes the tile ONLY. The session keeps running. Killing stays behind
|
||||||
`⋯ → Close session` and its existing confirm modal (`requestCloseSession`).
|
`⋯ → Close session` and its existing confirm modal (`requestCloseSession`).
|
||||||
|
|
||||||
@@ -196,16 +401,34 @@ animation frame, and sends one resize per affected tile at pointer-up.
|
|||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
||||||
Decided: per device, restored on reload. Stored in localStorage key
|
Decided: per device (per browser), restored on reload when it was open, and by
|
||||||
`codeman:tile-grid`:
|
the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
|
||||||
|
server:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
|
{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
|
||||||
"colFr": [1, 1, 1], "rowFr": [1, 1] }
|
"zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
|
Session ids and the layout, never content. `ids` are the CELLS in reading order,
|
||||||
duplicate ids on load. Never restored in a solo window.
|
`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
|
The restore runs INSIDE `handleInit`, in place of its initial
|
||||||
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
||||||
@@ -216,10 +439,22 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
|
|||||||
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
||||||
against the live list without rebuilding tiles that are still alive.
|
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
|
### Gating
|
||||||
|
|
||||||
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
||||||
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of
|
settings PUT, NOT in `SettingsUpdateSchema`), default ON on desktop and OFF on
|
||||||
|
handhelds (specified OFF; superseded, see "As built"). Independent of
|
||||||
`showSplitButton`, which is unchanged; a desk can show both buttons.
|
`showSplitButton`, which is unchanged; a desk can show both buttons.
|
||||||
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
|
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
|
||||||
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
|
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
|
||||||
@@ -227,6 +462,9 @@ against the live list without rebuilding tiles that are still alive.
|
|||||||
keeps the stored grid.
|
keeps the stored grid.
|
||||||
- Hidden in solo windows (`body.solo-mode`).
|
- Hidden in solo windows (`body.solo-mode`).
|
||||||
- `test/mobile-header-buttons-policy.test.ts` keeps it off phones.
|
- `test/mobile-header-buttons-policy.test.ts` keeps it off phones.
|
||||||
|
- The toggle chord follows the setting (decision 6): with `showTileGridButton`
|
||||||
|
off, `Ctrl+Shift+G` is inert and reaches the terminal like any unbound key; on,
|
||||||
|
it toggles the grid. A grid opened another way keeps all its chords.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -337,8 +575,9 @@ xterm. Nothing may outlive a removed tile (24-hour sessions rule).
|
|||||||
|
|
||||||
### 2. `TileGrid` controller and layout helper
|
### 2. `TileGrid` controller and layout helper
|
||||||
|
|
||||||
- `computeTileLayout({ count, width, height, minTileW, minTileH })` and
|
- `computeTileLayout({ count, width, height })` and
|
||||||
`tileGridCapacity(...)`: pure, in `constants.js`, exported on
|
`tileGridCapacity({ width, height })` (against the minimum tile size,
|
||||||
|
`TILE_MIN_W` x `TILE_MIN_H`): pure, in `constants.js`, exported on
|
||||||
`window.CodemanTileGrid` beside the existing helper namespaces.
|
`window.CodemanTileGrid` beside the existing helper namespaces.
|
||||||
`sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place.
|
`sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place.
|
||||||
- The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as
|
- The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as
|
||||||
@@ -562,7 +801,7 @@ and share one tile class:
|
|||||||
| A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) |
|
| A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) |
|
||||||
| Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) |
|
| Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) |
|
||||||
| Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other |
|
| Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other |
|
||||||
| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
|
| Multi-user mode | The grid only ever opens visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
|
||||||
| Solo window | Tiles unavailable |
|
| Solo window | Tiles unavailable |
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
@@ -613,10 +852,12 @@ Separate follow-up PRs worth doing (see "Follow-ups").
|
|||||||
- **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the
|
- **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the
|
||||||
`.strict()` schema; the `--hidden` marker class has a `display: none` rule.
|
`.strict()` schema; the `--hidden` marker class has a `display: none` rule.
|
||||||
- **Palette chords** are swallowed in every xterm key handler.
|
- **Palette chords** are swallowed in every xterm key handler.
|
||||||
- **Escape**: the picker's close method returns early when the picker is not
|
- **Escape**: the count menu's close method returns early when the menu is not
|
||||||
open (the global Escape handler calls every close method).
|
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`.
|
- **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()`.
|
- **Memory**: everything a tile creates is released in `destroy()`.
|
||||||
|
|
||||||
## Delivery: two PRs
|
## Delivery: two PRs
|
||||||
@@ -809,8 +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).
|
viewer keeps a stale width and renders garbled output (#464).
|
||||||
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
||||||
on drain) for grids over slow links.
|
on drain) for grids over slow links.
|
||||||
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
|
4. Tile parity extras: a "Load full history" action inside a tile. (Done
|
||||||
a "Load full history" action inside a tile.
|
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.
|
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
|
||||||
6. Named grid presets, possibly per owner on the server.
|
6. Named grid presets, possibly per owner on the server.
|
||||||
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
|
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
|
||||||
@@ -831,6 +1081,64 @@ exits green. Use the browser runner for those files and read the file count.
|
|||||||
default key (Ctrl+W is delete-word in every shell and agent CLI, and it
|
default key (Ctrl+W is delete-word in every shell and agent CLI, and it
|
||||||
killed sessions with no confirm); it stays bindable in App Settings →
|
killed sessions with no confirm); it stays bindable in App Settings →
|
||||||
Shortcuts.
|
Shortcuts.
|
||||||
|
6. **`Ctrl+Shift+G` with the Tiles setting off.** Decided by the owner: the
|
||||||
|
chord is inert while `showTileGridButton` is off (it passes through like any
|
||||||
|
unbound key) and toggles the grid while it is on, so one setting governs both
|
||||||
|
the button and the chord.
|
||||||
|
7. **The tile cap.** Decided by the owner: at most 6 tiles for now. Six was
|
||||||
|
tested and is smooth on the owner's desktop; nine missed the headless frame
|
||||||
|
bar (p95 33 ms at 6 and 9 tiles under load, 16.8 ms at 4) and is untested on
|
||||||
|
real hardware. The cap is one constant (`TILE_GRID_MAX`), the layout table
|
||||||
|
keeps 7 to 9 working but unreachable, and the user-facing texts say "at most
|
||||||
|
6 tiles" when the cap, not the window, is what limits the grid.
|
||||||
|
8. **The Tiles button opens the grid directly.** Decided by the owner ("when I
|
||||||
|
hit the tiles button, open the tiles already!"): a click opens the grid with
|
||||||
|
no picker in the way, choosing the grid this tab last had, else an open
|
||||||
|
split's two sessions, else the open sessions in tab order up to the cap with
|
||||||
|
the active one focused; `Ctrl+Shift+G` runs the same function. The picker
|
||||||
|
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; 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
|
||||||
|
and its right-click count menu, Ctrl/Cmd+click on a tab, a dragged tab,
|
||||||
|
"Open group as tiles" and Run joining the open grid.
|
||||||
|
10. **Right-click Tiles is a 2 / 4 / 6 count menu.** Decided by the owner
|
||||||
|
("give me then the option to choose only HOW many tiles, 2,4,6 default is 6
|
||||||
|
so the menu is easier"; asked where it lives: "Click opens 6"): a click
|
||||||
|
still opens the grid at once, with the remembered count (default 6); the
|
||||||
|
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;
|
||||||
|
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
|
## Code anchors
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
|||||||
programmatically is not supported (there is no stable library entry point).
|
programmatically is not supported (there is no stable library entry point).
|
||||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||||
Gesture Control (beta), Agent Teams
|
Gesture Control (beta), Agent Teams
|
||||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
|
||||||
in the UI or docs. These may change or be removed at any time.
|
(`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and
|
||||||
|
anything labeled experimental in the UI or docs. These may change or be
|
||||||
|
removed at any time.
|
||||||
|
|
||||||
## Deprecation policy
|
## Deprecation policy
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ output. The other CLIs expose no equivalent.
|
|||||||
| Respawn cycling and unattended runs | Yes | Yes |
|
| Respawn cycling and unattended runs | Yes | Yes |
|
||||||
| Cron jobs | Yes | Yes |
|
| Cron jobs | Yes | Yes |
|
||||||
| Docker cases, remote SSH cases | Yes | Yes |
|
| Docker cases, remote SSH cases | Yes | Yes |
|
||||||
| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
|
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
|
||||||
| Auto-resume when a usage limit resets | Yes | No |
|
| Auto-resume when a usage limit resets | Yes | No |
|
||||||
| Plan usage chip | Yes | No |
|
| Plan usage chip | Yes | No |
|
||||||
| Approvals Inbox | Yes | DeepSeek yes; others no |
|
| Approvals Inbox | Yes | DeepSeek yes; others no |
|
||||||
@@ -121,10 +121,24 @@ Renders its own TUI, so Codeman treats readiness as output stabilization rather
|
|||||||
watching for a prompt marker. Requires tmux, with no direct-PTY fallback, because its
|
watching for a prompt marker. Requires tmux, with no direct-PTY fallback, because its
|
||||||
environment is injected through socket-scoped `tmux setenv` rather than the command line.
|
environment is injected through socket-scoped `tmux setenv` rather than the command line.
|
||||||
|
|
||||||
|
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
|
||||||
|
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
|
||||||
|
session from an idle one. A pending permission prompt shows as idle, since it is waiting on
|
||||||
|
you. Before 1.40.0 an OpenCode session that had run a tool showed as working for good.
|
||||||
|
|
||||||
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
|
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
|
||||||
|
|
||||||
### Codex
|
### Codex
|
||||||
|
|
||||||
|
App Settings has synced **Default Codex model** and **Default Codex reasoning effort**
|
||||||
|
controls. Enter a model ID supported by your Codex provider; available reasoning levels
|
||||||
|
depend on the model and CLI version. Empty defaults use Codex's own configuration.
|
||||||
|
The defaults apply to local Codex sessions started from the Run menu, from Resume, and through
|
||||||
|
`POST /api/sessions` or `/api/quick-start`; scheduled (cron) jobs do not use them.
|
||||||
|
Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence.
|
||||||
|
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
|
||||||
|
Changing a default affects new sessions and does not edit Codex configuration files.
|
||||||
|
|
||||||
Two behaviours that are deliberate and worth knowing:
|
Two behaviours that are deliberate and worth knowing:
|
||||||
|
|
||||||
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
||||||
@@ -148,6 +162,11 @@ needs `GOOGLE_CLOUD_PROJECT`, `GOOGLE_APPLICATION_CREDENTIALS`, and
|
|||||||
`GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects
|
`GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects
|
||||||
only the CLI you spawned yourself.
|
only the CLI you spawned yourself.
|
||||||
|
|
||||||
|
Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line
|
||||||
|
(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool
|
||||||
|
confirmation that waits for you shows as idle. Before 1.40.0 a Gemini session showed as
|
||||||
|
working for good after its first turn.
|
||||||
|
|
||||||
### Antigravity
|
### Antigravity
|
||||||
|
|
||||||
Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state
|
Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state
|
||||||
@@ -169,6 +188,10 @@ Pi needs the opposite instincts from every other CLI here.
|
|||||||
`HF_TOKEN`, and so on) share no common prefix, and the environment allowlist is global
|
`HF_TOKEN`, and so on) share no common prefix, and the environment allowlist is global
|
||||||
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
|
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
|
||||||
once. They stay out.
|
once. They stay out.
|
||||||
|
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
|
||||||
|
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
|
||||||
|
that to tell working from idle. Before 1.40.0 a Pi session that had started a turn showed
|
||||||
|
as working for good.
|
||||||
|
|
||||||
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
|
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
|
||||||
|
|
||||||
@@ -222,7 +245,9 @@ documented default approval mode is `yolo`, so an OMP pane auto-approves tool us
|
|||||||
flag from Codeman; change that in OMP's own config, not here.
|
flag from Codeman; change that in OMP's own config, not here.
|
||||||
|
|
||||||
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
|
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
|
||||||
same conversation with `--continue`.
|
same conversation with `--continue`. Codeman tells working from idle by reading OMP's status
|
||||||
|
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.40.0
|
||||||
|
an OMP session that had started a turn showed as working for good.
|
||||||
|
|
||||||
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ npm run lint
|
|||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax
|
npm run check:frontend-syntax
|
||||||
npm run check:browser-excludes
|
npm run check:browser-excludes
|
||||||
npm test -- test/<file>.test.ts # one file, the normal way
|
npm test # the gate, exactly what CI runs
|
||||||
npm run test:ci # the full CI sweep
|
npm test -- test/<file>.test.ts # one file
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||||
@@ -53,13 +53,18 @@ something other than the checked-out HEAD, or when the tree has uncommitted chan
|
|||||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||||
`pre-push` hook of your own is never overwritten.
|
`pre-push` hook of your own is never overwritten.
|
||||||
|
|
||||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
`npm test` runs the same suite CI runs, so a green run locally means a green run there. It
|
||||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
|
||||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the
|
||||||
|
same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock
|
||||||
|
benchmarks for an otherwise idle machine). Expect those to fail where the machine cannot
|
||||||
|
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 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, pick a unique one at
|
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
|
||||||
3150 or above, and never 3000.
|
(`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`. Never 3000.
|
||||||
|
|
||||||
## Finding your way around
|
## Finding your way around
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ Codeman-side configuration:
|
|||||||
- Per-case toggles (Agent Teams, 1M Opus context).
|
- Per-case toggles (Agent Teams, 1M Opus context).
|
||||||
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
|
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
|
||||||
|
|
||||||
Three ways to get one, all under **+** next to the case picker:
|
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
|
||||||
|
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
|
||||||
|
|
||||||
| How | Result |
|
| How | Result |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
|
|||||||
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
|
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
|
||||||
supervising other sessions.**
|
supervising other sessions.**
|
||||||
|
|
||||||
Two routes. Start with the skill.
|
Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
|
||||||
|
`codeman agent` commands. Raw HTTP is there for everything else.
|
||||||
|
|
||||||
## The agent skill
|
## The agent skill
|
||||||
|
|
||||||
@@ -59,6 +60,36 @@ 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
|
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
|
||||||
completion signals.
|
completion signals.
|
||||||
|
|
||||||
|
## The `codeman agent` commands
|
||||||
|
|
||||||
|
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
|
||||||
|
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
|
||||||
|
that teaches it the API, 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 manual path
|
## The manual path
|
||||||
|
|
||||||
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
||||||
@@ -189,7 +220,7 @@ followed by a wait races, and reports the previous turn's state.
|
|||||||
## Lineage
|
## Lineage
|
||||||
|
|
||||||
A create request can name the session that spawned it, through a body field or a header, and
|
A create request can name the session that spawned it, through a body field or a header, and
|
||||||
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
||||||
|
|
||||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||||
failing the spawn, because a cosmetic field must never break a worker.
|
failing the spawn, because a cosmetic field must never break a worker.
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ codeman web # then open http://localhost:3000
|
|||||||
| Page | What it answers |
|
| Page | What it answers |
|
||||||
| ------------------------------------------ | ---------------------------------------------------------- |
|
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||||
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
|
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
|
||||||
|
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
|
||||||
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
||||||
|
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
||||||
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
||||||
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
||||||
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ from its tab, or bind a key to it in App Settings → Shortcuts.
|
|||||||
|
|
||||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||||
|
|
||||||
|
## Tile grid
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||||
|
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||||
|
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
|
||||||
|
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||||
|
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||||
|
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||||
|
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
|
||||||
|
|
||||||
|
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
||||||
|
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||||
|
default. See [Tile Grid](Tile-Grid).
|
||||||
|
|
||||||
## Everything else
|
## Everything else
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
|||||||
enforces it.
|
enforces it.
|
||||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||||
- The desktop home tab rail, which needs a wide window.
|
- The desktop home tab rail, which needs a wide window.
|
||||||
- Lineage arcs, which are a desktop overlay.
|
- Lineage lines, which are a desktop overlay.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -57,21 +57,30 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
|||||||
Chips for every optional header control, with a live preview of the resulting header:
|
Chips for every optional header control, with a live preview of the resulting header:
|
||||||
|
|
||||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||||
Ultracode Windows, Cron.
|
Ultracode Windows, Cron.
|
||||||
|
|
||||||
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
||||||
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
||||||
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
|
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
|
||||||
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
committed and pushed. Click it for the Git window; see
|
||||||
|
[Working With Files](Working-With-Files#git-changes). **Git status: group files
|
||||||
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
||||||
window; off lists every file by its full path.
|
window; off lists every file by its full path. **Git status: max repositories** (per device,
|
||||||
|
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
|
||||||
|
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
|
||||||
|
long one git command may run before that repository is reported as unreadable; raise it for
|
||||||
|
repositories on a slow network share.
|
||||||
|
|
||||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||||
|
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||||
|
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||||
resizable panes plus their divider have nowhere to go.
|
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
|
||||||
|
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
|
||||||
|
[Tile Grid](Tile-Grid).
|
||||||
|
|
||||||
This section also holds background-agent tracking, including whether to track agents for
|
This section also holds background-agent tracking, including whether to track agents for
|
||||||
every session or only the active tab.
|
every session or only the active tab.
|
||||||
@@ -81,18 +90,30 @@ every session or only the active tab.
|
|||||||
| Setting | Notes |
|
| Setting | Notes |
|
||||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
|
| 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. |
|
| 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. |
|
| 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). |
|
| 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). |
|
||||||
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
||||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
| Tab Layout | *Classic* (default): the single list as before. *By state*: a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. See [The Dashboard](The-Dashboard#tab-layouts). |
|
||||||
|
| 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. |
|
| 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. |
|
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||||
| Overview Home Screen | The phone home screen. On 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
|
### Models
|
||||||
|
|
||||||
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
||||||
@@ -127,13 +148,19 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
|||||||
| 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 | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
|
||||||
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
||||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||||
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
| 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. |
|
| 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
|
### Notifications
|
||||||
|
|
||||||
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
Master toggle, browser notifications, push subscription, audio alerts, how long a
|
||||||
|
corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
|
||||||
|
how long a desktop notification stays up before Codeman closes it (**Browser
|
||||||
|
notification display time**, default 8; both per device, and your OS may close a
|
||||||
|
desktop notification sooner), the idle
|
||||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||||
[Notifications And Approvals](Notifications-And-Approvals).
|
[Notifications And Approvals](Notifications-And-Approvals).
|
||||||
@@ -152,7 +179,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
|||||||
### System
|
### System
|
||||||
|
|
||||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||||
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
|
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||||
mode, the **Users** administration entry is injected here.
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Most of Codeman's UI is **opt-in**. A stock install shows a deliberately small h
|
|||||||
feature you read about here may simply not be on screen yet. Where that is the case, this
|
feature you read about here may simply not be on screen yet. Where that is the case, this
|
||||||
page says so and names the setting.
|
page says so and names the setting.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ page says so and names the setting.
|
|||||||
| **Header, left** | The "C" logo (goes home) and the session list, unless you moved it to the sidebar. |
|
| **Header, left** | The "C" logo (goes home) and the session list, unless you moved it to the sidebar. |
|
||||||
| **Header, right** | Status chips and panel buttons, most of them off by default. |
|
| **Header, right** | Status chips and panel buttons, most of them off by default. |
|
||||||
| **Center** | The terminal for the active session, or the home screen when nothing is selected. |
|
| **Center** | The terminal for the active session, or the home screen when nothing is selected. |
|
||||||
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counters. |
|
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counter. |
|
||||||
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
|
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
|
||||||
|
|
||||||
## Session list layout
|
## Session list layout
|
||||||
@@ -27,18 +27,56 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
|||||||
|
|
||||||
| Layout | Behaviour |
|
| Layout | Behaviour |
|
||||||
| -------------------- | --------------------------------------------------------------------------------- |
|
| -------------------- | --------------------------------------------------------------------------------- |
|
||||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
|
||||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. 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. Desktop and tablet only. |
|
||||||
|
|
||||||
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
||||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||||
per device, so a sidebar on your desktop does not force one onto your phone.
|
per device, so a sidebar on your desktop does not force one onto your phone.
|
||||||
|
|
||||||
|
## Tab layouts
|
||||||
|
|
||||||
|
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
|
||||||
|
|
||||||
|
| Layout | What it does |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||||
|
| **By state** | Groups the tabs by what each session needs from you (below). |
|
||||||
|
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
|
||||||
|
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
|
||||||
|
| **Classic** (default) | The single list in tab order, as before. |
|
||||||
|
|
||||||
|
**By state** groups the tabs like this, most urgent on top:
|
||||||
|
|
||||||
|
| Group | Who is in it |
|
||||||
|
| ------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
|
||||||
|
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
|
||||||
|
| **Working** | A turn is running. |
|
||||||
|
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||||
|
|
||||||
|
In the header each group is a row with its name and count on the left (Idle, the quiet
|
||||||
|
default, carries no label); a group with more tabs than fit on one line continues on the
|
||||||
|
next line. **State Order → Needs you at the
|
||||||
|
bottom** turns the rows the other way up, so the needs-you row sits right above the
|
||||||
|
terminal. Empty groups are not shown. These are the same states the phone overview and the
|
||||||
|
desktop home rail use, and tabs move between groups on their own as their state changes.
|
||||||
|
|
||||||
|
Both groupings also apply to the vertical rail and the left sidebar, as labelled sections.
|
||||||
|
Inside a group or a box tabs keep your tab order (on a rail sorted *By activity*, the
|
||||||
|
activity order), and the `Alt+1` to `Alt+9` numbers never change. Dragging reorders tabs
|
||||||
|
within a group or box. On a phone the strip stays a single scrolling row in group order,
|
||||||
|
without labels or boxes. If you have named tab groups in the vertical rail, those take
|
||||||
|
precedence there.
|
||||||
|
|
||||||
## Session tabs
|
## Session tabs
|
||||||
|
|
||||||
One tab per session, in your order, and that order syncs across your devices.
|
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:**
|
**Status is carried by the dot and the tab's own styling:**
|
||||||
|
|
||||||
| Look | Meaning |
|
| Look | Meaning |
|
||||||
@@ -84,13 +122,18 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
|
|||||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||||
|
|
||||||
### Lineage arcs
|
### Lineage lines
|
||||||
|
|
||||||
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
||||||
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||||
how a fan-out of eight workers stays readable.
|
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
|
||||||
|
a tab draws its own family thicker and brighter. A dashed branch means that child is
|
||||||
|
working. It is how a fan-out of eight workers stays readable.
|
||||||
|
|
||||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
|
While any tab has spawned another, the strip keeps a little extra room between rows for the
|
||||||
|
lines, so switching tabs never changes the header height.
|
||||||
|
|
||||||
|
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
|
||||||
skipped for tabs scrolled out of the strip.
|
skipped for tabs scrolled out of the strip.
|
||||||
|
|
||||||
## Header controls
|
## Header controls
|
||||||
@@ -102,7 +145,7 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
||||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
||||||
| CPU / MEM bars | On | Server resource use. |
|
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
|
||||||
| File Viewer | On | Toggles the file browser panel. |
|
| File Viewer | On | Toggles the file browser panel. |
|
||||||
| Settings gear | Always on | App Settings. |
|
| Settings gear | Always on | App Settings. |
|
||||||
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||||
@@ -118,9 +161,23 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| Cron ⏰ | Off | Scheduled jobs. |
|
| Cron ⏰ | Off | Scheduled jobs. |
|
||||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||||
|
| Tiles | On, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
|
||||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||||
| Admin panel | Multi-user only | User administration. |
|
| Admin panel | Multi-user only | User administration. |
|
||||||
|
|
||||||
|
### Header Stats Style
|
||||||
|
|
||||||
|
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
|
||||||
|
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
|
||||||
|
|
||||||
|
| Style | Look |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| **Tiles** | One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
|
||||||
|
| **Compact** | The default. Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
|
||||||
|
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
|
||||||
|
|
||||||
|
Hiding System Stats or Plan Usage still hides them in every style.
|
||||||
|
|
||||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||||
covered in [Mobile Guide](Mobile-Guide).
|
covered in [Mobile Guide](Mobile-Guide).
|
||||||
|
|
||||||
@@ -165,7 +222,9 @@ Worth knowing:
|
|||||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
||||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||||
always local scrollback. Other CLIs scroll locally.
|
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.
|
||||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||||
`Ctrl+Shift+C` always copies.
|
`Ctrl+Shift+C` always copies.
|
||||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Tile Grid
|
||||||
|
|
||||||
|
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
|
||||||
|
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
||||||
|
its agent is working, idle, or waiting on you.
|
||||||
|
|
||||||
|
The grid is a desktop feature. It needs a window at least about 1180px wide, and it is
|
||||||
|
never offered in a popped-out session window.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
**App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default
|
||||||
|
on desktops and laptops and off on phones and tablets, and the button only appears in a
|
||||||
|
window at least 1180px wide.
|
||||||
|
It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
||||||
|
|
||||||
|
## Opening a grid
|
||||||
|
|
||||||
|
- **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 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 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 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
|
||||||
|
another tile swaps the two.
|
||||||
|
- **"Open group as tiles"** in a tab group's menu, in the vertical tab rail with groups.
|
||||||
|
- **Run**: a session you start from this browser tab's Run button while the grid is open
|
||||||
|
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
||||||
|
|
||||||
|
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
||||||
|
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
|
||||||
|
window is too small for six; the count menu says which limit applies.
|
||||||
|
|
||||||
|
Opening, the tiles fade in one after another and each terminal appears once its history
|
||||||
|
has loaded, rather than scrolling through it. Closing with the button, the tiles stay
|
||||||
|
on screen, dimmed, until the single session behind them has loaded, then fade away. With
|
||||||
|
reduced motion turned on in your system settings, the grid opens and closes at once.
|
||||||
|
|
||||||
|
## A tile
|
||||||
|
|
||||||
|
Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
|
||||||
|
|
||||||
|
| Part | What it does |
|
||||||
|
| ------ | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
|
||||||
|
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
|
||||||
|
| name | Double-click to rename the session. |
|
||||||
|
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. OpenCode shows the model and its provider together (`Big Pickle OpenCode Zen`), exactly as its own composer does. |
|
||||||
|
| `⋯` | The session menu: options, open in a new window, close the session. |
|
||||||
|
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
|
||||||
|
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
|
||||||
|
|
||||||
|
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
|
||||||
|
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
|
||||||
|
voice and image paste. Tabs of tiled sessions carry a small underline.
|
||||||
|
|
||||||
|
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
|
||||||
|
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
|
||||||
|
one on its own until the window is big enough again.
|
||||||
|
|
||||||
|
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
|
||||||
|
whose agent exited inside its pane says so instead; close that session from `⋯`.
|
||||||
|
|
||||||
|
## Moving tiles
|
||||||
|
|
||||||
|
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
|
||||||
|
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
|
||||||
|
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
|
||||||
|
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
|
||||||
|
header focuses its tile when you click it, not when you press it.
|
||||||
|
|
||||||
|
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
|
||||||
|
down: into the empty slot if that is the place, else trading places with the tile there. It
|
||||||
|
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 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
|
||||||
|
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
|
||||||
|
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `Ctrl+Shift+G` | Open or close the grid. |
|
||||||
|
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||||
|
| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. |
|
||||||
|
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||||
|
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
|
||||||
|
| `Ctrl+L` | Clear the focused tile. |
|
||||||
|
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). |
|
||||||
|
|
||||||
|
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
|
||||||
|
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
|
||||||
|
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
|
||||||
|
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
|
||||||
|
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
|
||||||
|
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
|
||||||
|
|
||||||
|
## Leaving the grid
|
||||||
|
|
||||||
|
Clicking the tab of a session that is not tiled (or picking it with `Alt+1-9` or the
|
||||||
|
session finder) shows that session on its own, the normal single view. The grid is
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
The grid and Split are never open together: opening the grid turns an open split into two
|
||||||
|
tiles, and Split is unavailable while the grid is open.
|
||||||
|
|
||||||
|
## Read next
|
||||||
|
|
||||||
|
- [The Dashboard](The-Dashboard) - the single view, tabs and the header.
|
||||||
|
- [Keyboard Shortcuts](Keyboard-Shortcuts) - every binding.
|
||||||
|
- [Settings Reference](Settings-Reference) - where the Tiles setting lives.
|
||||||
@@ -22,13 +22,18 @@ transcript: what it was asked to do, what it is doing, and what it returned.
|
|||||||
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
||||||
eight workers looks like a stalled terminal for several minutes.
|
eight workers looks like a stalled terminal for several minutes.
|
||||||
|
|
||||||
## Session lineage arcs
|
## Session lineage lines
|
||||||
|
|
||||||
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
|
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||||
whole sessions started by an agent through the API.
|
agent through the API.
|
||||||
|
|
||||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
|
The lines form one tree per spawning tab, in that tab's colour, and run only through the
|
||||||
|
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
|
||||||
|
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
|
||||||
|
dashed branch means that child is working.
|
||||||
|
|
||||||
|
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
|
||||||
for tabs scrolled out of view.
|
for tabs scrolled out of view.
|
||||||
|
|
||||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||||
@@ -42,7 +47,8 @@ in the CLI's own environment:
|
|||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
||||||
```
|
```
|
||||||
|
|
||||||
and turn the per-case **Agent Teams** toggle on in the case settings gear.
|
and turn the per-case **Agent Teams** toggle on under **Case settings…** at the bottom of the case
|
||||||
|
picker (the gear beside the case button on a phone).
|
||||||
|
|
||||||
Codeman watches the team directory and matches teammates to the session leading them.
|
Codeman watches the team directory and matches teammates to the session leading them.
|
||||||
Teammates are in-process threads rather than separate CLI processes, so they show up as
|
Teammates are in-process threads rather than separate CLI processes, so they show up as
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ It renders what it can:
|
|||||||
| Kind | Behaviour |
|
| Kind | Behaviour |
|
||||||
| ------------------------ | ------------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------- |
|
||||||
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
| 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. |
|
| Images | Inline. |
|
||||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
| 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. |
|
||||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||||
| Anything else | Download. |
|
| Anything else | Download. |
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ surface as an artifact attachment rather than a path you have to go and find.
|
|||||||
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
||||||
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
||||||
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||||
conflicts, `✓` when everything is committed and pushed.
|
conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed.
|
||||||
|
|
||||||
Click it for a draggable window, in the style of the File Viewer:
|
Click it for a draggable window, in the style of the File Viewer:
|
||||||
|
|
||||||
@@ -187,7 +188,10 @@ Click it for a draggable window, in the style of the File Viewer:
|
|||||||
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
||||||
note instead, and a diff over 400 KB is cut short.
|
note instead, and a diff over 400 KB is cut short.
|
||||||
- A session folder that holds several projects gets one collapsible section per repository
|
- A session folder that holds several projects gets one collapsible section per repository
|
||||||
found up to two levels down. They all start collapsed (each summary line shows its branch and
|
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
|
||||||
|
when there are more). A repository git could not read, typically a timeout on a slow network
|
||||||
|
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
|
||||||
|
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
|
||||||
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||||
in your home folder) is ignored.
|
in your home folder) is ignored.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
**Using it**
|
**Using it**
|
||||||
|
|
||||||
- [The Dashboard](The-Dashboard)
|
- [The Dashboard](The-Dashboard)
|
||||||
|
- [Tile Grid](Tile-Grid)
|
||||||
- [Agent CLIs](Agent-CLIs)
|
- [Agent CLIs](Agent-CLIs)
|
||||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||||
- [Working With Files](Working-With-Files)
|
- [Working With Files](Working-With-Files)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.35.0",
|
"version": "1.41.0",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"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.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
|
"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",
|
"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",
|
"knip": "npx --yes knip@latest --config config/knip.json",
|
||||||
"release": "changeset publish"
|
"release": "node scripts/npm-release.mjs"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
@@ -129,6 +129,8 @@
|
|||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
"exceljs": "4.4.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"pixelmatch": "^6.0.0",
|
"pixelmatch": "^6.0.0",
|
||||||
"playwright": "^1.58.0",
|
"playwright": "^1.58.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
@@ -169,7 +171,7 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Ark0N/Codeman/issues"
|
"url": "https://github.com/Ark0N/Codeman/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/Ark0N/Codeman#readme",
|
"homepage": "https://getcodeman.com",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"scripts/postinstall.js",
|
"scripts/postinstall.js",
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
# xterm-zerolag-input
|
# 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
|
## 0.4.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
|
|||||||
|
|
||||||
### Scroll awareness
|
### 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",
|
"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",
|
"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",
|
"type": "module",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
|
|||||||
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
|
|||||||
return '';
|
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,
|
FontStyle,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { getCellDimensions } from './cell-dimensions.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';
|
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
|
||||||
|
|
||||||
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
||||||
@@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
// Cache font properties
|
// Cache font properties
|
||||||
this._cacheFont();
|
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 = () => {
|
this._scrollHandler = () => {
|
||||||
try {
|
try {
|
||||||
const buf = this._terminal!.buffer.active;
|
if (!promptRowInViewport(this._terminal!)) {
|
||||||
if (buf.viewportY !== buf.baseY) {
|
|
||||||
this._overlay!.style.display = 'none';
|
this._overlay!.style.display = 'none';
|
||||||
if (this._scrollTimer) {
|
if (this._scrollTimer) {
|
||||||
clearTimeout(this._scrollTimer);
|
clearTimeout(this._scrollTimer);
|
||||||
@@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
try {
|
try {
|
||||||
const buf = this._terminal.buffer.active;
|
const buf = this._terminal.buffer.active;
|
||||||
|
|
||||||
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
|
// Hide the overlay while the cursor row is scrolled out of view
|
||||||
if (buf.viewportY !== buf.baseY) {
|
if (!promptRowInViewport(this._terminal)) {
|
||||||
this._overlay.style.display = 'none';
|
this._overlay.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createMockTerminal } from './helpers.js';
|
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';
|
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
||||||
|
|
||||||
function term(lines: string[]) {
|
function term(lines: string[]) {
|
||||||
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
|
|||||||
cleanup();
|
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",
|
"name": "codeman",
|
||||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.35.0",
|
"version": "1.41.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
|||||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||||
|
|
||||||
|
Workers in **every other mode** never receive this preamble, but they have the same
|
||||||
|
environment: tell them *"other sessions: `codeman agent --help`"* — the 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.
|
||||||
|
|
||||||
## 0. Guard and bootstrap
|
## 0. Guard and bootstrap
|
||||||
|
|
||||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Extracted from the package.json one-liner for readability and debuggability.
|
* Extracted from the package.json one-liner for readability and debuggability.
|
||||||
*
|
*
|
||||||
* Steps:
|
* Steps:
|
||||||
|
* 0. Preflight: the build-time packages resolve (nothing is touched before it)
|
||||||
* 1. TypeScript compilation
|
* 1. TypeScript compilation
|
||||||
* 2. Copy static assets (web/public, templates)
|
* 2. Copy static assets (web/public, templates)
|
||||||
* 3. Build vendor xterm bundles
|
* 3. Build vendor xterm bundles
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
import { createRequire } from 'module';
|
||||||
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -25,6 +27,31 @@ function run(label, cmd) {
|
|||||||
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
|
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0. Preflight: resolve the build-time packages the asset stage reads only AFTER it has
|
||||||
|
// deleted dist/web/public (step 2), before anything is touched. A tree whose node_modules
|
||||||
|
// predate them (a deploy that pulled but never ran `npm install`) used to fail mid-build
|
||||||
|
// with dist/web/public already wiped, so the running server kept serving an index.html
|
||||||
|
// whose hashed assets were gone. Keep the list in step with every require.resolve in
|
||||||
|
// scripts/prepare-spreadsheet-assets.mjs (test/spreadsheet-assets.test.ts checks it).
|
||||||
|
// Only specifiers that resolve without an exports map in the way: a subpath of a package
|
||||||
|
// that has one (@xterm/*) can throw ERR_PACKAGE_PATH_NOT_EXPORTED while installed.
|
||||||
|
// A hand-run of the asset stage alone (past a blocked tsc) skips this check.
|
||||||
|
const BUILD_TIME_MODULES = ['exceljs/dist/exceljs.min.js', 'fflate'];
|
||||||
|
const requireFromBuild = createRequire(import.meta.url);
|
||||||
|
const missingModules = BUILD_TIME_MODULES.filter((specifier) => {
|
||||||
|
try {
|
||||||
|
requireFromBuild.resolve(specifier);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (missingModules.length > 0) {
|
||||||
|
console.error(`[build] missing build dependency: ${missingModules.join(', ')}`);
|
||||||
|
console.error('[build] run `npm install` first, then `npm run build` again. Nothing was built or deleted.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. TypeScript compilation
|
// 1. TypeScript compilation
|
||||||
run('tsc', 'tsc');
|
run('tsc', 'tsc');
|
||||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||||
@@ -49,6 +76,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
|
|||||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||||
|
// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
|
||||||
|
// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
|
||||||
|
run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
|
||||||
|
|
||||||
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||||
appendFileSync(
|
appendFileSync(
|
||||||
@@ -129,6 +159,7 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'api-client.js',
|
'api-client.js',
|
||||||
'subagent-windows.js',
|
'subagent-windows.js',
|
||||||
'image-input.js',
|
'image-input.js',
|
||||||
|
'spreadsheet-preview.js',
|
||||||
'vendor/xterm-zerolag-input.js',
|
'vendor/xterm-zerolag-input.js',
|
||||||
'vendor/xterm-predictive-echo.js',
|
'vendor/xterm-predictive-echo.js',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { dirname, extname, join, relative, resolve } from 'node:path';
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|||||||
const publicRoot = resolve(repoRoot, 'src/web/public');
|
const publicRoot = resolve(repoRoot, 'src/web/public');
|
||||||
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
||||||
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
||||||
|
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
|
||||||
|
// They load only inside the spreadsheet worker, but a dependency bump that
|
||||||
|
// balloons them should be a deliberate decision, not a silent one.
|
||||||
|
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
|
||||||
|
|
||||||
function collectTextAssets(dir) {
|
function collectTextAssets(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
|
|||||||
const files = collectTextAssets(publicRoot);
|
const files = collectTextAssets(publicRoot);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
|
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
|
||||||
|
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
|
||||||
|
// the content hash of everything the worker loads, or a deploy can pair a new
|
||||||
|
// worker with a stale cached core/vendor file (static assets are cached 1y).
|
||||||
|
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
|
||||||
|
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
|
||||||
|
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
|
||||||
|
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
|
||||||
|
|
||||||
|
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
|
||||||
|
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
|
||||||
|
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
|
||||||
|
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedVersion = createHash('sha256')
|
||||||
|
.update(readFileSync(spreadsheetWorker))
|
||||||
|
.update(readFileSync(spreadsheetCore))
|
||||||
|
.update(readFileSync(spreadsheetVendors[0]))
|
||||||
|
.update(readFileSync(spreadsheetVendors[1]))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12);
|
||||||
|
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
|
||||||
|
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
|
||||||
|
if (actualVersion !== expectedVersion) {
|
||||||
|
failures.push(
|
||||||
|
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const rel = relative(repoRoot, file);
|
const rel = relative(repoRoot, file);
|
||||||
const data = readFileSync(file);
|
const data = readFileSync(file);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
|
||||||
|
*
|
||||||
|
* npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
|
||||||
|
* @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
|
||||||
|
* keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
|
||||||
|
* Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
|
||||||
|
* `files` cannot help: npm-packlist always includes every root `README.*`.
|
||||||
|
*
|
||||||
|
* Renaming the file would break every link to it, so for the length of the publish only it
|
||||||
|
* moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
|
||||||
|
* whatever the publish did. The move happens HERE, inside the publish command, never as a
|
||||||
|
* step before `changesets/action` in release.yml: that action also runs the version path and
|
||||||
|
* commits the working tree into its version PR, which would commit the deletion.
|
||||||
|
*
|
||||||
|
* A previous run killed between the move and the restore leaves the aside copy behind; the
|
||||||
|
* next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
|
||||||
|
*
|
||||||
|
* node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
|
||||||
|
*/
|
||||||
|
import { existsSync, renameSync } from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export const HIDDEN_README = 'README.zh-CN.md';
|
||||||
|
export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
|
||||||
|
* when `publish` fails or throws. Returns the exit code `publish` returned.
|
||||||
|
*
|
||||||
|
* @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
|
||||||
|
const original = join(root, HIDDEN_README);
|
||||||
|
const aside = join(root, ASIDE_NAME);
|
||||||
|
if (existsSync(aside) && !existsSync(original)) {
|
||||||
|
renameSync(aside, original);
|
||||||
|
log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
|
||||||
|
}
|
||||||
|
const moved = existsSync(original);
|
||||||
|
if (moved) {
|
||||||
|
renameSync(original, aside);
|
||||||
|
log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return publish();
|
||||||
|
} finally {
|
||||||
|
if (moved) {
|
||||||
|
renameSync(aside, original);
|
||||||
|
log(`npm-release: ${HIDDEN_README} restored`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runChangesetPublish() {
|
||||||
|
const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return result.status ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
|
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||||
|
process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
|
||||||
|
}
|
||||||
@@ -341,6 +341,22 @@ if (isGlobalInstall) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
|
||||||
|
// src/web/public/vendor/ for dev mode. The build does the same into dist/.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (!isGlobalInstall) {
|
||||||
|
try {
|
||||||
|
execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
|
||||||
|
console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
|
||||||
|
} catch (err) {
|
||||||
|
hasWarnings = true;
|
||||||
|
console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
|
||||||
|
console.log(colors.dim(` ${err.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
||||||
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
|
||||||
|
* dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
|
||||||
|
* build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
|
||||||
|
* in package.json, and check-public-assets.mjs hashes the output into
|
||||||
|
* SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
|
||||||
|
* that changes the bytes fails that check until the token is refreshed.
|
||||||
|
* Source-map comments are stripped: the maps are not shipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
|
||||||
|
const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
|
||||||
|
const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
|
||||||
|
|
||||||
|
function copyBrowserBundle(source, outputName) {
|
||||||
|
const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
|
||||||
|
if (/sourceMappingURL/.test(content)) {
|
||||||
|
throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
|
||||||
|
}
|
||||||
|
writeFileSync(join(outputDir, outputName), content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
copyBrowserBundle(excelSource, 'exceljs.min.js');
|
||||||
|
copyBrowserBundle(fflateSource, 'fflate.min.js');
|
||||||
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
|||||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||||
|
|
||||||
|
Workers in **every other mode** never receive this preamble, but they have the same
|
||||||
|
environment: tell them *"other sessions: `codeman agent --help`"* — the 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.
|
||||||
|
|
||||||
## 0. Guard and bootstrap
|
## 0. Guard and bootstrap
|
||||||
|
|
||||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||||
|
|||||||
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|||||||
*/
|
*/
|
||||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document types an attachment card previews. Also the list `codeman attach`'s
|
||||||
|
* error text names, so the help cannot drift from what is accepted. `xlsx` is
|
||||||
|
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
|
||||||
|
*/
|
||||||
|
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
|
||||||
|
|
||||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
'jpeg',
|
'jpeg',
|
||||||
'gif',
|
'gif',
|
||||||
'webp',
|
'webp',
|
||||||
'pdf',
|
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||||
'docx',
|
|
||||||
'pptx',
|
|
||||||
'md',
|
'md',
|
||||||
'txt',
|
'txt',
|
||||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||||
@@ -154,6 +159,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
|
|||||||
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
||||||
if (normalized === 'pdf') return 'pdf';
|
if (normalized === 'pdf') return 'pdf';
|
||||||
if (normalized === 'pptx') return 'presentation';
|
if (normalized === 'pptx') return 'presentation';
|
||||||
|
if (normalized === 'xlsx') return 'spreadsheet';
|
||||||
if (normalized === 'md') return 'markdown';
|
if (normalized === 'md') return 'markdown';
|
||||||
// Everything else in the text family reads as text, including code and
|
// Everything else in the text family reads as text, including code and
|
||||||
// config: the card and the preview both treat it as a plain-text file.
|
// config: the card and the preview both treat it as a plain-text file.
|
||||||
|
|||||||
@@ -0,0 +1,980 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
|
||||||
|
|
||||||
|
/** Every request carries these; they are ignored on endpoints that do not read them. */
|
||||||
|
export function baseHeaders(ctx: AgentContext): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||||
|
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||||
|
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||||
|
// may create one (see agentSpawn), never anything else.
|
||||||
|
'X-Codeman-Parent-Session': ctx.selfId,
|
||||||
|
};
|
||||||
|
const 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,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -15,15 +15,17 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||||||
import { isAbsolute, join } from 'node:path';
|
import { isAbsolute, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dataPath } from './config/instance.js';
|
import { dataPath } from './config/instance.js';
|
||||||
|
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||||
import { casePath } from './config/cases-dir.js';
|
import { casePath } from './config/cases-dir.js';
|
||||||
import { assertValidBasePath } from './config/base-path.js';
|
import { assertValidBasePath } from './config/base-path.js';
|
||||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||||
|
import { registerAgentCommands } from './cli-agent.js';
|
||||||
import { getSessionManager } from './session-manager.js';
|
import { getSessionManager } from './session-manager.js';
|
||||||
import { getTaskQueue } from './task-queue.js';
|
import { getTaskQueue } from './task-queue.js';
|
||||||
import { getRalphLoop } from './ralph-loop.js';
|
import { getRalphLoop } from './ralph-loop.js';
|
||||||
import { getStore } from './state-store.js';
|
import { getStore } from './state-store.js';
|
||||||
import { getErrorMessage } from './types.js';
|
import { getErrorMessage } from './types.js';
|
||||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||||
@@ -42,32 +44,8 @@ function makeAttachmentMagicLink(filePath: string): string {
|
|||||||
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
|
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> {
|
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
|
||||||
const envFile = readCodemanEnv();
|
const { username, password } = readCodemanCredentials();
|
||||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
|
||||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
|
||||||
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
|
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
|
||||||
const body = JSON.stringify({ path: filePath });
|
const body = JSON.stringify({ path: filePath });
|
||||||
const transport = url.protocol === 'https:' ? https : http;
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
@@ -111,7 +89,11 @@ program
|
|||||||
.action(async (filePath, options) => {
|
.action(async (filePath, options) => {
|
||||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
console.error(
|
||||||
|
palette.err(
|
||||||
|
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
|
||||||
|
)
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +230,10 @@ skillCmd
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Agent Commands (session-to-session, any CLI mode) ============
|
||||||
|
|
||||||
|
registerAgentCommands(program);
|
||||||
|
|
||||||
// ============ Session Commands ============
|
// ============ Session Commands ============
|
||||||
|
|
||||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||||
@@ -637,9 +623,7 @@ function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
|
|||||||
} catch {
|
} catch {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
const envFile = readCodemanEnv();
|
const { username, password } = readCodemanCredentials();
|
||||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
|
||||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
|
||||||
const transport = url.protocol === 'https:' ? https : http;
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
if (password) {
|
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')}`;
|
||||||
|
}
|
||||||
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many capture groups a regex source declares (named ones included), or -1 when it
|
||||||
|
* does not compile. Matching the empty string against `source|` always succeeds through
|
||||||
|
* the empty alternative, and the match array then has one slot per group.
|
||||||
|
*/
|
||||||
|
export function countCaptureGroups(source: string): number {
|
||||||
|
try {
|
||||||
|
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
|
||||||
|
} catch {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||||
import type { McpConfigFormat } from './types.js';
|
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||||
|
|
||||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||||
const cliId = z
|
const cliId = z
|
||||||
@@ -289,7 +289,7 @@ const capabilitiesSchema = z
|
|||||||
requiresMux: z.boolean(),
|
requiresMux: z.boolean(),
|
||||||
hooks: z.enum(['none', 'always', 'supervised']),
|
hooks: z.enum(['none', 'always', 'supervised']),
|
||||||
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
|
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
|
||||||
altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']),
|
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
||||||
echo: echoSchema,
|
echo: echoSchema,
|
||||||
wheelForward: z
|
wheelForward: z
|
||||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
||||||
@@ -320,6 +320,8 @@ const capabilitiesSchema = z
|
|||||||
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
||||||
// transcript layout nobody has measured is never touched.
|
// transcript layout nobody has measured is never touched.
|
||||||
transcriptGutter: z.number().int().min(1).max(8).optional(),
|
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
|
workDetect: z
|
||||||
.object({
|
.object({
|
||||||
promptGlyph: z.string().min(1).max(8),
|
promptGlyph: z.string().min(1).max(8),
|
||||||
@@ -370,6 +372,56 @@ const capabilitiesSchema = z
|
|||||||
model: z
|
model: z
|
||||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||||
.strict(),
|
.strict(),
|
||||||
|
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
|
||||||
|
// over the foot of a pane capture every time a session settles. Exactly one capture
|
||||||
|
// group (the model), checked here so a pattern without one fails at LOAD time instead
|
||||||
|
// of silently never naming a model.
|
||||||
|
modelDetect: z
|
||||||
|
.object({
|
||||||
|
screenLine: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
|
||||||
|
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// Bounded hard, like watchingLines: every row it adds is one more row the agent
|
||||||
|
// itself may be able to write. 8 is the reader's own cap (readScreenModel); a
|
||||||
|
// window taller than the CLI's footer needs a pattern only that CLI's chrome can
|
||||||
|
// satisfy at its position, as opencode's does by taking the LAST composer row.
|
||||||
|
screenLines: z.number().int().min(1).max(8).optional(),
|
||||||
|
// Single tokens, bounded: each is compared against one captured field.
|
||||||
|
rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(),
|
||||||
|
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
|
||||||
|
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
// Typos rather than configurations, refused at LOAD time like watchingLines.
|
||||||
|
.refine(
|
||||||
|
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
|
||||||
|
'modelDetect declares nothing to read'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) => v.screenLines === undefined || v.screenLine !== undefined,
|
||||||
|
'screenLines has nothing to bound without a screenLine'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) => v.rejectWords === undefined || v.screenLine !== undefined,
|
||||||
|
'rejectWords has nothing to filter without a screenLine'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// Launch param -> synced App Settings key. The values are a closed enum, like
|
||||||
|
// configResolver: a clis.json override names one of the settings this build knows
|
||||||
|
// how to validate, never an arbitrary key. Params are checked against the declared
|
||||||
|
// ones in the superRefine below.
|
||||||
|
launchDefaults: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
|
||||||
|
)
|
||||||
|
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
|
||||||
|
.optional(),
|
||||||
privilegedParams: z
|
privilegedParams: z
|
||||||
.array(
|
.array(
|
||||||
z
|
z
|
||||||
@@ -399,6 +451,7 @@ const capabilitiesSchema = z
|
|||||||
'codex-toml',
|
'codex-toml',
|
||||||
'opencode-json',
|
'opencode-json',
|
||||||
'antigravity-json',
|
'antigravity-json',
|
||||||
|
'copilot-json',
|
||||||
] as const satisfies readonly McpConfigFormat[]),
|
] as const satisfies readonly McpConfigFormat[]),
|
||||||
// The env var the CLI reads to move the file, and the path under it (same no-traversal
|
// 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.
|
// rule: sync writes there too). Resolved from the server env at call time, never here.
|
||||||
@@ -588,6 +641,30 @@ export const CliEntrySchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Same silent-no-op class again: a launch default for a param the entry never declared
|
||||||
|
// would be filled into the config object and then read by nothing. And without a
|
||||||
|
// `legacyConfigField` the entry's params are read off the request body itself, where a
|
||||||
|
// filled `model` would be a different field (claude's per-session one), so refuse it.
|
||||||
|
const { launchDefaults } = entry.capabilities;
|
||||||
|
if (launchDefaults !== undefined) {
|
||||||
|
if (entry.launch.legacyConfigField === undefined) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'launchDefaults needs launch.legacyConfigField to fill',
|
||||||
|
path: ['capabilities', 'launchDefaults'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const param of Object.keys(launchDefaults)) {
|
||||||
|
if (!declaredParams.has(param)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: `launchDefaults param "${param}" is not a declared launch param`,
|
||||||
|
path: ['capabilities', 'launchDefaults', param],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { setenvProfile } = entry.env;
|
const { setenvProfile } = entry.env;
|
||||||
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
|
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
|
|||||||
// in them, so a copy can drop two and paste flush. Claude and codex are the only
|
// 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.
|
// entries that declare this, because theirs are the only gutters that have been measured.
|
||||||
transcriptGutter: 2,
|
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
|
// 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`
|
// `✻ 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.
|
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||||
@@ -491,8 +494,45 @@ const OPENCODE: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-and-mouse',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||||
|
// 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
|
||||||
|
// with the same bar, so a turn's first repaint arms the idle confirmation and tmux's
|
||||||
|
// reattach repaint does the same for a restored pane. While a turn runs the footer row
|
||||||
|
// starts with an 8-cell knight-rider spinner, `⬝■■■■■■⬝ esc interrupt`, redrawn about
|
||||||
|
// every 40 ms (never a 2.5 s gap mid-turn, so silence cannot end one early); at rest the
|
||||||
|
// row holds only the key hints and nothing on screen draws a `⬝`/`■` run, the wide
|
||||||
|
// layout's sidebar included. The working line is the spinner run, not the label: tmux
|
||||||
|
// ships `esc` and `interrupt` as separate words joined by cursor moves, and below about
|
||||||
|
// 45 columns the footer wraps the label itself. A pending permission prompt replaces
|
||||||
|
// the composer and stops the spinner, so it reads as idle (waiting on the user).
|
||||||
|
// ⚠️ Without this entry an opencode session latched `busy` after any turn that ran a
|
||||||
|
// tool: the braille spinner on a running tool row trips SPINNER_PATTERN, and opencode
|
||||||
|
// never draws Claude's `❯`, the fallback that would have armed the idle check. The
|
||||||
|
// last `┃` row on screen is the composer's agent/model row (or the permission box's
|
||||||
|
// closing bar), never the prompt text, so the submit verifier stands down.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '┃',
|
||||||
|
workingLine: '[⬝■]{8}',
|
||||||
|
},
|
||||||
|
// The composer's agent row, measured on live opencode 1.3.0 panes (home screen and in
|
||||||
|
// session, at 40, 60, 120 and 200 columns, 2026-10-09): `┃ Build Big Pickle OpenCode
|
||||||
|
// Zen`, directly above the box's bottom edge `╹▀▀▀`. opencode renders it as the agent,
|
||||||
|
// then the model's name, then the provider's name (then `· <variant>` when the model
|
||||||
|
// has one), and only colour tells model from provider, so the field is all of it: what
|
||||||
|
// opencode itself shows, owner's choice. A double space ends it, which is where the
|
||||||
|
// 200-column layout's sidebar shares the row. The lookahead takes the LAST such row in
|
||||||
|
// the window, so nothing the agent prints higher up can stand in for it; below the
|
||||||
|
// composer there is only opencode's own chrome (key hints, a tip, the cwd/version
|
||||||
|
// row), which is why the window can be 8 rows: the home screen puts up to 5 of those
|
||||||
|
// rows under it. A permission prompt or shell mode hides the row, and the last model
|
||||||
|
// is kept. `No provider ` is opencode's placeholder before a provider is connected.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`┃ {2}[^\s·]+ {2}(?!No provider )([^ \n](?:[^ \n]| (?! ))*)(?: {2}.*)?\n *╹(?![\s\S]*\n *╹)`,
|
||||||
|
screenLines: 8,
|
||||||
|
},
|
||||||
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||||
mcpConfig: {
|
mcpConfig: {
|
||||||
path: '.config/opencode/opencode.json',
|
path: '.config/opencode/opencode.json',
|
||||||
@@ -598,10 +638,15 @@ const CODEX: CliEntry = {
|
|||||||
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
|
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
|
||||||
// the terminal starts, follows the composer down as the conversation grows, and is
|
// the terminal starts, follows the composer down as the conversation grows, and is
|
||||||
// gone after `/stop`.
|
// gone after `/stop`.
|
||||||
|
// ⚠️ Codex 0.162.0 (measured 2026-10-09) draws a hint row under the status line at
|
||||||
|
// rest (` ← for agents · ? for shortcuts`) and drops it while a prompt is typed, so
|
||||||
|
// the chip is FOURTH from the bottom at rest and third while typing. A three-row
|
||||||
|
// window never saw it at rest, which is exactly when the idle probe reads it, so a
|
||||||
|
// session waiting on its terminal read as plainly idle. Four rows cover both.
|
||||||
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
|
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
|
||||||
// layout rather than its pattern. The third row from the bottom is the chip only
|
// layout rather than its pattern. The fourth row from the bottom is the chip only
|
||||||
// while a terminal runs; with none running it is the last row of the transcript,
|
// while a terminal runs; with none running it is the last row of the transcript,
|
||||||
// which the agent writes. Matching the complete row raises the bar — an assistant
|
// which the agent writes (and while a prompt is typed, the last two). Matching the complete row raises the bar — an assistant
|
||||||
// message has to end with this exact line, to the character — but nothing here makes
|
// message has to end with this exact line, to the character — but nothing here makes
|
||||||
// forging it impossible, so do not read the Claude comment above as applying here.
|
// forging it impossible, so do not read the Claude comment above as applying here.
|
||||||
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
|
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
|
||||||
@@ -620,8 +665,37 @@ const CODEX: CliEntry = {
|
|||||||
promptGlyph: '›',
|
promptGlyph: '›',
|
||||||
workingLine: '[Ee]sc to interrupt',
|
workingLine: '[Ee]sc to interrupt',
|
||||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||||
watchingLines: 3,
|
watchingLines: 4,
|
||||||
},
|
},
|
||||||
|
// The footer under the composer, measured on a live 0.147.0 pane:
|
||||||
|
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
|
||||||
|
// cwd). It sits below the composer, so the transcript never reaches it, and the
|
||||||
|
// effort word right after the model is codex's own format: an open slash-command
|
||||||
|
// popup or a bare line of prose does not have that shape. A footer without an effort
|
||||||
|
// word (a model with no reasoning setting) is not read, and the session keeps its
|
||||||
|
// last known or launch model.
|
||||||
|
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
|
||||||
|
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
|
||||||
|
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
|
||||||
|
// that level never named its model. Every word is plain letters, so the join adds no
|
||||||
|
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
|
||||||
|
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
|
||||||
|
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
|
||||||
|
// drops it again while a prompt is being typed. With a one-row window the footer was
|
||||||
|
// never seen and every codex tile showed no model. So the window is two rows and the
|
||||||
|
// footer is either the last one or followed by exactly one more two-space-indented
|
||||||
|
// row. The `$` (no `m` flag: the end of the window) is what keeps the guard the
|
||||||
|
// one-row rule had: with the footer hidden, the last two rows are a transcript line
|
||||||
|
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
|
||||||
|
// an indented row, so it is not read.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||||
|
screenLines: 2,
|
||||||
|
},
|
||||||
|
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
|
||||||
|
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
|
||||||
|
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
|
||||||
|
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
|
||||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||||
@@ -744,6 +818,28 @@ const GEMINI: CliEntry = {
|
|||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-full',
|
altScreen: 'strip-full',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
|
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
|
||||||
|
// turns with a shell call at 40, 120 and 200 columns, YOLO and default approval mode,
|
||||||
|
// plus the raw PTY stream, 2026-10-09). The TUI repaints its whole bottom region on
|
||||||
|
// every frame, composer included, and the composer sits between a `▄` bar and a `▀`
|
||||||
|
// bar; the submitted prompt is echoed between the same bars. So the `▀` bar arms the
|
||||||
|
// idle confirmation (every repaint and tmux's reattach repaint carry it), and it is the
|
||||||
|
// glyph rather than the composer's prompt character, which follows the approval mode
|
||||||
|
// (`*` in YOLO) and whose `>` also starts the echoed prompt. While a turn runs a line
|
||||||
|
// `⠦ Thinking... (esc to cancel, 6s)` animates about every 80 ms (largest gap mid-turn:
|
||||||
|
// 214 ms); the label can be any loading phrase, so the working line is the
|
||||||
|
// `(esc to cancel, <n>` suffix, or a spinner frame opening a line where a long phrase
|
||||||
|
// pushed that suffix onto the next one. At rest nothing on screen matches either. A tool
|
||||||
|
// confirmation (default mode) replaces the composer and stops the spinner, and the pane
|
||||||
|
// goes silent, so it reads as idle (waiting on the user).
|
||||||
|
// ⚠️ Without this entry a gemini session latched `busy` after its first turn: the braille
|
||||||
|
// spinner trips SPINNER_PATTERN, and gemini never draws Claude's `❯`, the fallback that
|
||||||
|
// would have armed the idle check. A line starting with `▀` is a bar, never prompt text,
|
||||||
|
// so the submit verifier stands down.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '▀',
|
||||||
|
workingLine: String.raw`\(esc to cancel, \d|(?:^|\n) ?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] `,
|
||||||
|
},
|
||||||
// gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must
|
// gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must
|
||||||
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
||||||
// sends no geminiConfig at all would still get yolo for free.
|
// sends no geminiConfig at all would still get yolo for free.
|
||||||
@@ -925,6 +1021,37 @@ const PI: CliEntry = {
|
|||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
|
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
|
||||||
|
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
|
||||||
|
// while a turn runs it embeds its status in the TOP rule as `── ⠏ Working ───…`, the
|
||||||
|
// braille frame animating every ~80 ms; at rest both rules are plain `─`. So the rule
|
||||||
|
// is the glyph that arms the idle confirmation, and a spinner frame inside it is the
|
||||||
|
// working line (the frame, not the word: an extension can replace "Working").
|
||||||
|
// ⚠️ Without this entry a pi session never left `busy` once marked working: the
|
||||||
|
// braille spinner trips SPINNER_PATTERN, and pi never draws Claude's `❯`, the
|
||||||
|
// fallback that would have armed the idle check. The rules carry no prompt text, so
|
||||||
|
// the submit verifier reading them stands down instead of re-pressing Enter.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '─',
|
||||||
|
workingLine: '── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] ',
|
||||||
|
},
|
||||||
|
// pi's footer stats row, read from pi 1.1.0's footer code (0.84.4's is the same) and
|
||||||
|
// measured live as `0.8%/253k (auto) qwen3.8-27b-pi • xhigh`: usage and
|
||||||
|
// context on the left, then at least two spaces and `[(provider) ]<model>` followed
|
||||||
|
// by ` • <thinking>` for a reasoning model and ` → <routed model>` when routed. Only
|
||||||
|
// the last two rows are read, which sit below the composer where the transcript never
|
||||||
|
// reaches (an extension's status row may sit under the stats row), and the context
|
||||||
|
// field (`12.3%/253k`, `?/128k`) picks the stats row out of them.
|
||||||
|
// ⚠️ A narrow pane truncates the right side with NO ellipsis, leaving exactly two
|
||||||
|
// spaces of padding. So a model with nothing after it is read only with 3+ spaces in
|
||||||
|
// front; with two, only when a following ` •`/` →` proves the name is whole (the
|
||||||
|
// bullet only ever follows a complete name, even when the cut lands right after it).
|
||||||
|
// A cut name is never shown. `no-model` is pi's placeholder when none is selected.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`[%?]/[\d.]+[kKM]?(?: \(auto\))?(?: • xp)? {2}(?: +|(?=(?:\(\S{1,40}\) )?\S{1,80} [•→]))(?:\([\w.@-]{1,40}\) )?([A-Za-z0-9][\w.:/@+-]{0,79})(?= [•→]|\n|$)`,
|
||||||
|
screenLines: 2,
|
||||||
|
rejectWords: ['no-model'],
|
||||||
|
},
|
||||||
// pi's absent-config default is an interactive trust PROMPT the session user could
|
// pi's absent-config default is an interactive trust PROMPT the session user could
|
||||||
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
|
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
|
||||||
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
|
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
|
||||||
@@ -1042,8 +1169,9 @@ const GROK: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
// Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity:
|
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
|
||||||
// only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
|
||||||
|
// erase-scrollback+DECSET strip.
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
|
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
|
||||||
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
|
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
|
||||||
@@ -1217,11 +1345,42 @@ const DEEPSEEK: CliEntry = {
|
|||||||
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
|
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
|
||||||
// the session can disarm the bridge, and docker/remote cannot reach it at all.
|
// the session can disarm the bridge, and docker/remote cannot reach it at all.
|
||||||
hooks: 'supervised',
|
hooks: 'supervised',
|
||||||
|
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
|
||||||
|
composerReadyMark: '❯',
|
||||||
transcript: 'deepseek-zstd',
|
transcript: 'deepseek-zstd',
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
// Model is NOT a session field for dsh — it is a profile composition entry.
|
// Model is NOT a session field for dsh — it is a profile composition entry.
|
||||||
model: { source: 'none' },
|
model: { source: 'none' },
|
||||||
|
// So the screen is where the model is known: dsh-TUI resolves the route itself
|
||||||
|
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
|
||||||
|
// lib/types/modelRoute.js) and its status line draws "the route requests actually
|
||||||
|
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
|
||||||
|
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
|
||||||
|
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
|
||||||
|
// The border anchors it: nothing the agent writes can sit below the composer, and a
|
||||||
|
// suggestion popup there starts with `/` or `+`, never a model id.
|
||||||
|
// ⚠ The first field is the model only while the status bar's model field is on (the
|
||||||
|
// default). Switched off, the first field is the next one (StatusLine.js): tokens per
|
||||||
|
// second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match,
|
||||||
|
// then the reasoning effort (` medium · th-config`, measured live), then the session
|
||||||
|
// mode, then the cwd's basename. So `rejectWords` lists what those can be, from the
|
||||||
|
// dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's
|
||||||
|
// THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode
|
||||||
|
// ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token,
|
||||||
|
// and a field equal to the session's folder name is refused by the shared reader.
|
||||||
|
// Known gaps, all off by default: a custom mode id drawn raw, a git branch or a
|
||||||
|
// one-word session title as the first field; and the non-compact layout, whose
|
||||||
|
// left/right justification never ends a field with ` · `, so nothing is read there
|
||||||
|
// and the session shows its route config.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
||||||
|
screenLines: 3,
|
||||||
|
rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'],
|
||||||
|
// With the status bar's model field off (or before it paints), the route the
|
||||||
|
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
|
||||||
|
configResolver: 'deepseek-route',
|
||||||
|
},
|
||||||
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
||||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||||
// `read-only` instead would break the workspace rather than protect it.
|
// `read-only` instead would break the workspace rather than protect it.
|
||||||
@@ -1347,13 +1506,27 @@ const OMP: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
// Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the
|
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
|
||||||
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
|
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
|
||||||
// makes an omp conversation survive a full session kill.
|
// makes an omp conversation survive a full session kill.
|
||||||
transcript: 'omp-jsonl',
|
transcript: 'omp-jsonl',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
|
// Measured on live omp 18.8.6 and 18.0.11 panes (2026-10-09, a turn held open against
|
||||||
|
// an endpoint that never answers): the input row is `╰─ <text>`, redrawn when a turn
|
||||||
|
// ends, at launch and on reattach. While a turn runs the status bar's leading `π`
|
||||||
|
// becomes a braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > 📁 ~/dir ▶──`;
|
||||||
|
// 18.0.11 pads it with two spaces, past a minute it reads `1m`), and a `⎋ Working…`
|
||||||
|
// row appears above it. At rest the bar starts ` π > `.
|
||||||
|
// ⚠️ Without this entry an omp session never left `busy` once marked working, like pi:
|
||||||
|
// the spinner trips SPINNER_PATTERN and omp never draws Claude's `❯` after setup.
|
||||||
|
// The glyph also switches the submit verifier on for omp. A prompt sent mid-turn goes
|
||||||
|
// to omp's `Steering` queue and clears the input row, so the verifier stands down.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '╰─',
|
||||||
|
workingLine: '[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [0-9hms ]+> |⎋ Working',
|
||||||
|
},
|
||||||
// No permission prompts and no bypass flag, so nothing config-shaped to clamp — the
|
// No permission prompts and no bypass flag, so nothing config-shaped to clamp — the
|
||||||
// whole privileged surface here is env-shaped.
|
// whole privileged surface here is env-shaped.
|
||||||
privilegedParams: [],
|
privilegedParams: [],
|
||||||
|
|||||||
@@ -93,8 +93,25 @@ export interface CliVariant {
|
|||||||
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||||
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||||
|
|
||||||
|
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
|
||||||
|
export type ModelConfigResolverName = 'deepseek-route';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
|
||||||
|
* A closed list rather than any settings key, so a clis.json override cannot feed an
|
||||||
|
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
|
||||||
|
* key, which the resolver's typing enforces.
|
||||||
|
*/
|
||||||
|
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
|
||||||
|
|
||||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
/** 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 {
|
export interface CliLaunch {
|
||||||
params: Record<string, ParamSpec>;
|
params: Record<string, ParamSpec>;
|
||||||
@@ -362,8 +379,8 @@ export interface CliCapabilities {
|
|||||||
/**
|
/**
|
||||||
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
||||||
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
|
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
|
||||||
* its own above the composer, which puts it third from the bottom, so it declares
|
* its own above the composer, which puts it third or fourth from the bottom (its hint
|
||||||
* more. Keep each number as small as that CLI's layout allows: every extra row is
|
* row comes and goes), so it declares more. Keep each number as small as that CLI's layout allows: every extra row is
|
||||||
* another row an agent might be able to write, and the label is what silences an
|
* another row an agent might be able to write, and the label is what silences an
|
||||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||||
*/
|
*/
|
||||||
@@ -405,6 +422,17 @@ export interface CliCapabilities {
|
|||||||
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
||||||
*/
|
*/
|
||||||
transcriptGutter?: number;
|
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). */
|
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||||
requiresMux: boolean;
|
requiresMux: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -436,11 +464,40 @@ export interface CliCapabilities {
|
|||||||
*/
|
*/
|
||||||
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
|
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
|
||||||
/**
|
/**
|
||||||
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
|
* What the server strips from this CLI's output stream before the browser sees it.
|
||||||
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
|
* The value encodes three independent choices (predicates in session.ts):
|
||||||
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
|
*
|
||||||
|
* | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs |
|
||||||
|
* |-----------------------|---------------------|-------------------------|---------------------|
|
||||||
|
* | `strip-full` | stripped | stripped | stripped |
|
||||||
|
* | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux |
|
||||||
|
* | `strip-mux-only` | stripped under tmux | kept | kept |
|
||||||
|
* | `preserve` | stripped under tmux | kept | kept |
|
||||||
|
*
|
||||||
|
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
|
||||||
|
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
|
||||||
|
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
|
||||||
|
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
|
||||||
|
*
|
||||||
|
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
|
||||||
|
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
|
||||||
|
* reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a
|
||||||
|
* direct-PTY fallback the `?1049h` is the program's own and must stay.
|
||||||
|
* - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's
|
||||||
|
* redraw-driven `3J` (strip-full) is noise.
|
||||||
|
* - mouse DECSETs: stripping them keeps a drag a local selection instead of a report
|
||||||
|
* to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated
|
||||||
|
* on the `cliMouseTracking` the server records as it strips. Kept where a program's
|
||||||
|
* own mouse support must work in the pane (htop/vim in a shell).
|
||||||
|
*
|
||||||
|
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
|
||||||
|
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
|
||||||
|
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
|
||||||
|
*
|
||||||
|
* A fourth combination is the point to split this into flags; three is still cheaper
|
||||||
|
* as an enum.
|
||||||
*/
|
*/
|
||||||
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
|
altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve';
|
||||||
echo: {
|
echo: {
|
||||||
policy: 'buffer' | 'predict' | 'off';
|
policy: 'buffer' | 'predict' | 'off';
|
||||||
/** How the local-echo overlay locates the composer row. */
|
/** How the local-echo overlay locates the composer row. */
|
||||||
@@ -462,6 +519,55 @@ export interface CliCapabilities {
|
|||||||
statusLineTelemetry: boolean;
|
statusLineTelemetry: boolean;
|
||||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||||
|
/**
|
||||||
|
* Where this CLI draws the model it is running, so a session header can name it
|
||||||
|
* (`SessionState.displayModel`, src/session-display-model.ts).
|
||||||
|
*
|
||||||
|
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
|
||||||
|
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
|
||||||
|
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
|
||||||
|
* extra tmux call and re-reads the footer at every turn transition, so an in-session
|
||||||
|
* `/model` switch is followed.
|
||||||
|
*
|
||||||
|
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
|
||||||
|
* on chrome only this CLI draws (the row under its own composer, an effort word in its
|
||||||
|
* own footer format), never on a shape the agent could print in its transcript. Measured
|
||||||
|
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
|
||||||
|
* the session shows its launch model, if any.
|
||||||
|
*
|
||||||
|
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
|
||||||
|
* model the CLI's own config pins, the way that CLI resolves it for the session, for
|
||||||
|
* while the screen names none (its status line switched off, or not drawn yet). Read
|
||||||
|
* once per pane start, attach or relaunch, bounded and read-only; the screen still
|
||||||
|
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
|
||||||
|
* per-CLI behaviour stays data here and code in one module.
|
||||||
|
*/
|
||||||
|
modelDetect?: {
|
||||||
|
screenLine?: string;
|
||||||
|
screenLines?: number;
|
||||||
|
/**
|
||||||
|
* Words the `screenLine` field can show when it is NOT the model (a footer whose model
|
||||||
|
* field is switched off shows the next field there), compared lower-cased. A field
|
||||||
|
* equal to the session's own working-directory basename is never the model either,
|
||||||
|
* for every CLI; that rule is the shared reader's, not data.
|
||||||
|
*/
|
||||||
|
rejectWords?: string[];
|
||||||
|
configResolver?: ModelConfigResolverName;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
|
||||||
|
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
|
||||||
|
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
|
||||||
|
*
|
||||||
|
* Filled into the entry's `launch.legacyConfigField` object at create time by
|
||||||
|
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
|
||||||
|
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
|
||||||
|
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
|
||||||
|
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
|
||||||
|
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
|
||||||
|
* Absent = no launch defaults.
|
||||||
|
*/
|
||||||
|
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
|
||||||
/**
|
/**
|
||||||
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
||||||
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to
|
||||||
|
* use, read from its route config, for a session header whose screen names no model
|
||||||
|
* yet (the status bar's model field switched off, or not drawn yet). See
|
||||||
|
* `SessionState.displayModel` (src/session-display-model.ts): the screen still wins
|
||||||
|
* whenever it names a model, since it is what the running TUI actually uses.
|
||||||
|
*
|
||||||
|
* ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1)
|
||||||
|
*
|
||||||
|
* A profile is a stack of loader patch layers over an empty root, in this order
|
||||||
|
* (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own
|
||||||
|
* `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the home-level
|
||||||
|
* `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch`
|
||||||
|
* overlays (Codeman passes none). A patch targets a row by `id`; one whose `name`
|
||||||
|
* does not match the row's is skipped; every other key REPLACES the row's field
|
||||||
|
* whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui`
|
||||||
|
* row defines all of it.
|
||||||
|
*
|
||||||
|
* dsh-TUI then takes its model route from that config only when it names BOTH
|
||||||
|
* `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is
|
||||||
|
* dropped whole and the TUI falls back to the persisted `/model` choice, then to its
|
||||||
|
* own default: neither is in the config, so neither is answered here. The bundle's
|
||||||
|
* own row pins `provider: deepseek-official` alone, by design, so only the two user
|
||||||
|
* layers can pin a route; the bundle layers are not read (they resolve through the dsh
|
||||||
|
* installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the
|
||||||
|
* HEADLESS default, not the TUI's, and is never read.
|
||||||
|
*
|
||||||
|
* ## Answer nothing rather than a guess
|
||||||
|
*
|
||||||
|
* Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned
|
||||||
|
* route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too
|
||||||
|
* big, a mount that does not answer), and a file this reader does not fully
|
||||||
|
* understand. The YAML reader below is deliberately narrow (the repo carries no YAML
|
||||||
|
* dependency): a top-level block sequence of patch items, plain keys, single-line
|
||||||
|
* plain or quoted string scalars for the values it needs, and null for anything else
|
||||||
|
* that could change the answer (an anchor, alias or tag such as `!!js` on such a value,
|
||||||
|
* a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a
|
||||||
|
* scalar YAML would type as a number, boolean or null, a second document, a nested
|
||||||
|
* row redefining dsh-TUI).
|
||||||
|
*
|
||||||
|
* ## Never block, never write, never leak
|
||||||
|
*
|
||||||
|
* Every path is probed with the bounded `probePathKind()` before it is touched, read
|
||||||
|
* asynchronously with a size cap, and must resolve (realpath) inside the dsh home.
|
||||||
|
* Nothing is written. Only the model id leaves this module: never the provider, a
|
||||||
|
* base URL, a key or any other config value.
|
||||||
|
*
|
||||||
|
* Tests: `test/deepseek-route-config.test.ts`.
|
||||||
|
*
|
||||||
|
* @module deepseek-route-config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||||
|
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||||
|
import {
|
||||||
|
deepSeekProfileFromManifest,
|
||||||
|
isProfileDirName,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
type DeepSeekProfile,
|
||||||
|
} from './utils/deepseek-cli-resolver.js';
|
||||||
|
|
||||||
|
/** The dsh-TUI bundle a profile must compose for its route to be read here. */
|
||||||
|
export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
|
||||||
|
/** The loader row dsh-TUI's own config lives on. */
|
||||||
|
export const DSH_TUI_ROW_ID = 'dsh-tui';
|
||||||
|
/** Largest file read: a patch layer is a few dozen lines. */
|
||||||
|
export const MAX_ROUTE_FILE_BYTES = 64 * 1024;
|
||||||
|
/** A profile name as the launch accepts it (the `path-segment` token pattern). */
|
||||||
|
const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||||
|
/** How many profile directories the default-profile inventory looks at. */
|
||||||
|
const MAX_PROFILES = 64;
|
||||||
|
|
||||||
|
/** What one patch item does to the dsh-TUI row. */
|
||||||
|
export interface DshTuiRowPatch {
|
||||||
|
/** `name` on the patch; a mismatch makes dsh skip it. */
|
||||||
|
name?: string;
|
||||||
|
/** `disabled` on the patch, when present. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* `config` on the patch, when present: the provider and model it names (absent when
|
||||||
|
* it does not name one), or `{}` for a config that is empty or not a mapping.
|
||||||
|
*/
|
||||||
|
config?: { provider?: string; model?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown inside the parser for anything it does not fully understand. */
|
||||||
|
class Ambiguous extends Error {}
|
||||||
|
|
||||||
|
/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */
|
||||||
|
const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/;
|
||||||
|
|
||||||
|
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a line's comment (`#` at line start or after whitespace, outside quotes) and
|
||||||
|
* trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is
|
||||||
|
* beyond this reader.
|
||||||
|
*/
|
||||||
|
function stripComment(line: string): string {
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quote === "'") {
|
||||||
|
if (c === "'") {
|
||||||
|
if (line[i + 1] === "'") i++;
|
||||||
|
else quote = null;
|
||||||
|
}
|
||||||
|
} else if (quote === '"') {
|
||||||
|
if (c === '\\') i++;
|
||||||
|
else if (c === '"') quote = null;
|
||||||
|
} else if (c === "'" || c === '"') {
|
||||||
|
// A quote opens a scalar only at its start; inside a plain scalar it is a character.
|
||||||
|
const prev = line.slice(0, i).trimEnd();
|
||||||
|
if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c;
|
||||||
|
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||||
|
return line.slice(0, i).trimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quote) throw new Ambiguous('unterminated quote');
|
||||||
|
return line.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */
|
||||||
|
function indentOf(line: string): number {
|
||||||
|
const m = /^[ \t]*/.exec(line)![0];
|
||||||
|
if (m.includes('\t')) throw new Ambiguous('tab indentation');
|
||||||
|
return m.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-line scalar as a string: plain, or single/double-quoted. Throws on anything
|
||||||
|
* that is not plainly a string (a tag, an anchor, an alias, a flow collection, a
|
||||||
|
* block scalar, or a plain scalar YAML would type as null, a boolean or a number).
|
||||||
|
*/
|
||||||
|
function stringScalar(raw: string): string {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (v.startsWith("'")) {
|
||||||
|
const m = /^'((?:[^']|'')*)'$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/''/g, "'");
|
||||||
|
}
|
||||||
|
if (v.startsWith('"')) {
|
||||||
|
const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/\\(["\\/])/g, '$1');
|
||||||
|
}
|
||||||
|
if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string');
|
||||||
|
if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar');
|
||||||
|
if (
|
||||||
|
/^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test(
|
||||||
|
v
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Ambiguous('numeric scalar');
|
||||||
|
}
|
||||||
|
if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true`/`false` as YAML spells them, or a throw. */
|
||||||
|
function boolScalar(raw: string): boolean {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (/^(?:true|True|TRUE)$/.test(v)) return true;
|
||||||
|
if (/^(?:false|False|FALSE)$/.test(v)) return false;
|
||||||
|
throw new Ambiguous('not a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
indent: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct keys of a block mapping whose lines all sit at `indent` or deeper, each
|
||||||
|
* with its inline value and the lines nested under it. Throws on a line that is not a
|
||||||
|
* key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh
|
||||||
|
* would not boot).
|
||||||
|
*/
|
||||||
|
function mappingKeys(lines: Line[], indent: number): Map<string, { inline: string | undefined; nested: Line[] }> {
|
||||||
|
const keys = new Map<string, { inline: string | undefined; nested: Line[] }>();
|
||||||
|
let current: { inline: string | undefined; nested: Line[] } | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent > indent) {
|
||||||
|
if (!current) throw new Ambiguous('nested line with no key');
|
||||||
|
current.nested.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.indent < indent) throw new Ambiguous('dedent inside a mapping');
|
||||||
|
const m = KEY_LINE.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`);
|
||||||
|
if (keys.has(m[1])) throw new Ambiguous('duplicate key');
|
||||||
|
current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] };
|
||||||
|
keys.set(m[1], current);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an item this reader cannot follow is certainly about another row: a plain
|
||||||
|
* `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no
|
||||||
|
* mention of the dsh-TUI row anywhere in it.
|
||||||
|
*/
|
||||||
|
function isUnrelatedItem(item: Line[]): boolean {
|
||||||
|
const indent = item[0].indent;
|
||||||
|
const top = item.filter((l) => l.indent === indent);
|
||||||
|
if (top.some((l) => /^insert\s*:/.test(l.text))) return false;
|
||||||
|
const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id');
|
||||||
|
if (ids.length !== 1 || ids[0]![2] === undefined) return false;
|
||||||
|
try {
|
||||||
|
return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */
|
||||||
|
function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } {
|
||||||
|
if (entry.inline !== undefined) {
|
||||||
|
if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines');
|
||||||
|
const v = entry.inline.trim();
|
||||||
|
// An empty flow mapping or a null names no route; anything else inline (a tag, a
|
||||||
|
// non-empty flow mapping, a block scalar) is beyond this reader.
|
||||||
|
if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {};
|
||||||
|
throw new Ambiguous('inline config');
|
||||||
|
}
|
||||||
|
if (!entry.nested.length) return {};
|
||||||
|
const keys = mappingKeys(entry.nested, entry.nested[0].indent);
|
||||||
|
const out: { provider?: string; model?: string } = {};
|
||||||
|
for (const field of ['provider', 'model'] as const) {
|
||||||
|
const value = keys.get(field);
|
||||||
|
if (!value) continue;
|
||||||
|
if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`);
|
||||||
|
out[field] = stringScalar(value.inline);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a cordis patch-list file (a top-level YAML array of loader patches) does to the
|
||||||
|
* dsh-TUI row, in order. An empty list when the file does not touch it. Null when the
|
||||||
|
* file is beyond this reader's subset, or when it could re-insert the row.
|
||||||
|
*
|
||||||
|
* @param text the file's content
|
||||||
|
*/
|
||||||
|
export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null {
|
||||||
|
try {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
let sawContent = false;
|
||||||
|
for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
||||||
|
const stripped = stripComment(rawLine);
|
||||||
|
if (stripped.trim() === '') continue;
|
||||||
|
const indent = indentOf(stripped);
|
||||||
|
const body = stripped.slice(indent);
|
||||||
|
if (indent === 0 && body === '---') {
|
||||||
|
if (sawContent) throw new Ambiguous('a second document');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sawContent = true;
|
||||||
|
lines.push({ indent, text: body });
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return [];
|
||||||
|
|
||||||
|
// Split the top-level block sequence into items.
|
||||||
|
const items: Line[][] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent === 0) {
|
||||||
|
const m = /^-(?:(\s+)(.*))?$/.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous('not a top-level sequence');
|
||||||
|
const item: Line[] = [];
|
||||||
|
// `- key: value`: the key sits at its real column, which its siblings below share.
|
||||||
|
if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] });
|
||||||
|
items.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (items.length === 0) throw new Ambiguous('indented content before the first item');
|
||||||
|
items[items.length - 1].push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches: DshTuiRowPatch[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.length === 0) throw new Ambiguous('empty item');
|
||||||
|
// The first key's column is the item's indent; every key shares it.
|
||||||
|
let keys: ReturnType<typeof mappingKeys>;
|
||||||
|
try {
|
||||||
|
keys = mappingKeys(item, item[0].indent);
|
||||||
|
} catch (err) {
|
||||||
|
// An item this reader cannot follow is harmless only when it provably is about
|
||||||
|
// another row: its own id names one, and no line in it names the dsh-TUI row.
|
||||||
|
if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err;
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = keys.get('id');
|
||||||
|
if (keys.has('insert')) {
|
||||||
|
// An insert that could bring a second dsh-TUI row is beyond this reader.
|
||||||
|
const body = item.map((l) => l.text).join('\n');
|
||||||
|
if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!id) continue; // dsh warns and skips a non-insert patch without an id
|
||||||
|
if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar');
|
||||||
|
if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) {
|
||||||
|
// Another row; but a group row's config is a list of rows, and one of them could
|
||||||
|
// be a second dsh-TUI row (dsh indexes nested group entries by id too).
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const patch: DshTuiRowPatch = {};
|
||||||
|
const name = keys.get('name');
|
||||||
|
if (name) {
|
||||||
|
if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar');
|
||||||
|
patch.name = stringScalar(name.inline);
|
||||||
|
}
|
||||||
|
const disabled = keys.get('disabled');
|
||||||
|
if (disabled) {
|
||||||
|
if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar');
|
||||||
|
patch.disabled = boolScalar(disabled.inline);
|
||||||
|
}
|
||||||
|
const config = keys.get('config');
|
||||||
|
if (config) patch.config = configOf(config);
|
||||||
|
patches.push(patch);
|
||||||
|
}
|
||||||
|
return patches;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Ambiguous) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model dsh-TUI's route config pins, given what the user layers do to its row in
|
||||||
|
* application order (profile layer first, then the home layer). Null unless the last
|
||||||
|
* `config` that applies names both a provider and a model, and the row is not
|
||||||
|
* disabled. Pure.
|
||||||
|
*
|
||||||
|
* @param layers each layer's patches for the row, or null for a layer that could not be read
|
||||||
|
*/
|
||||||
|
export function resolveDshTuiRouteModel(layers: Array<DshTuiRowPatch[] | null>): string | null {
|
||||||
|
let config: { provider?: string; model?: string } | undefined;
|
||||||
|
let disabled = false;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer === null) return null;
|
||||||
|
for (const patch of layer) {
|
||||||
|
if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue;
|
||||||
|
if (patch.disabled !== undefined) disabled = patch.disabled;
|
||||||
|
if (patch.config !== undefined) config = patch.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disabled || !config?.provider || !config.model) return null;
|
||||||
|
return config.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */
|
||||||
|
type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount
|
||||||
|
* that does not answer is refused, never waited on), its real path checked against
|
||||||
|
* the dsh home (a symlink out of it is refused), size-capped.
|
||||||
|
*/
|
||||||
|
async function readHomeFile(path: string, realHome: string): Promise<FileRead> {
|
||||||
|
const kind = await probePathKind(path);
|
||||||
|
if (kind === 'absent') return { state: 'absent' };
|
||||||
|
if (kind !== 'file') return { state: 'refused' };
|
||||||
|
try {
|
||||||
|
const real = await fs.realpath(path);
|
||||||
|
if (!real.startsWith(realHome + sep)) return { state: 'refused' };
|
||||||
|
const stat = await fs.stat(real);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' };
|
||||||
|
return { state: 'read', text: await fs.readFile(real, 'utf8') };
|
||||||
|
} catch {
|
||||||
|
return { state: 'refused' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dsh home a session runs against: its own `DSH_HOME` (already clamped for a
|
||||||
|
* non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's
|
||||||
|
* `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place.
|
||||||
|
*/
|
||||||
|
export function effectiveDshHome(env: (key: string) => string | undefined): string | null {
|
||||||
|
const value = env('DSH_HOME')?.trim();
|
||||||
|
if (!value) return join(homedir(), '.dsh');
|
||||||
|
return isAbsolute(value) ? resolve(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profiles under a dsh home, read with the same bounded rules as the route files.
|
||||||
|
* Used to name the profile a session boots when it named none, the way the launch's
|
||||||
|
* `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile).
|
||||||
|
*/
|
||||||
|
async function listProfilesBounded(home: string): Promise<DeepSeekProfile[] | null> {
|
||||||
|
const profilesDir = join(home, 'profiles');
|
||||||
|
if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
names = (await fs.readdir(profilesDir, { withFileTypes: true }))
|
||||||
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.slice(0, MAX_PROFILES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profiles: DeepSeekProfile[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') continue;
|
||||||
|
const profile = deepSeekProfileFromManifest(name, manifest.text);
|
||||||
|
if (profile) profiles.push(profile);
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the reader needs to know about one session. */
|
||||||
|
export interface DeepSeekRouteContext {
|
||||||
|
/** The session's `deepSeekConfig.profile`, if any. */
|
||||||
|
profile?: unknown;
|
||||||
|
/** The session's dsh home (see {@link effectiveDshHome}). */
|
||||||
|
home: string | null;
|
||||||
|
/** The server's own dsh home, which names the default profile (as the launch does). */
|
||||||
|
serverHome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the session's dsh-TUI route config pins, or null when it pins none or the
|
||||||
|
* answer is in any doubt. Read-only and bounded; see the module comment.
|
||||||
|
*/
|
||||||
|
export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise<string | null> {
|
||||||
|
const { home } = ctx;
|
||||||
|
if (!home) return null;
|
||||||
|
// An invalid name reads as unset at launch, so the default applies there too.
|
||||||
|
let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null;
|
||||||
|
if (!profile) {
|
||||||
|
if (!ctx.serverHome) return null;
|
||||||
|
const listed = await listProfilesBounded(ctx.serverHome);
|
||||||
|
profile = listed ? resolveDefaultDeepSeekProfile(listed) : null;
|
||||||
|
if (!profile) return null;
|
||||||
|
}
|
||||||
|
if ((await probePathKind(home)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profileDir = join(home, 'profiles', profile);
|
||||||
|
const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') return null;
|
||||||
|
if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null;
|
||||||
|
|
||||||
|
const layers: Array<DshTuiRowPatch[] | null> = [];
|
||||||
|
for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) {
|
||||||
|
const read = await readHomeFile(file, realHome);
|
||||||
|
if (read.state === 'refused') return null;
|
||||||
|
layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text));
|
||||||
|
}
|
||||||
|
return resolveDshTuiRouteModel(layers);
|
||||||
|
}
|
||||||
@@ -495,6 +495,10 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
|
|||||||
SSH_ASKPASS_REQUIRE: 'never',
|
SSH_ASKPASS_REQUIRE: 'never',
|
||||||
DISPLAY: '',
|
DISPLAY: '',
|
||||||
GCM_INTERACTIVE: 'never',
|
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:
|
GIT_SSH_COMMAND:
|
||||||
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
|
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||||
* to the outer one, and is not scanned;
|
* to the outer one, and is not scanned;
|
||||||
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||||
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
|
* DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules`
|
||||||
* symlinks), each reported separately;
|
* and the like, never following symlinks), each reported separately;
|
||||||
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
||||||
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
||||||
*
|
*
|
||||||
@@ -52,7 +52,10 @@ import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 10_000;
|
/** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
|
||||||
|
export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
|
||||||
|
export const MIN_GIT_TIMEOUT_MS = 5_000;
|
||||||
|
export const MAX_GIT_TIMEOUT_MS = 120_000;
|
||||||
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||||
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||||
/** Max file rows returned. The counts stay exact. */
|
/** Max file rows returned. The counts stay exact. */
|
||||||
@@ -258,9 +261,9 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
||||||
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
|
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||||
|
|
||||||
export const runGit: GitRunner = async (cwd, args) => {
|
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||||
@@ -269,7 +272,7 @@ export const runGit: GitRunner = async (cwd, args) => {
|
|||||||
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
timeout: GIT_TIMEOUT_MS,
|
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
||||||
maxBuffer: MAX_OUTPUT_BYTES,
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||||
}
|
}
|
||||||
@@ -288,17 +291,14 @@ function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
|||||||
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
|
async function collect(cwd: string, git: GitRunner, timeoutMs?: number): Promise<GitWorkspaceStatus> {
|
||||||
let statusText: string;
|
let statusText: string;
|
||||||
try {
|
try {
|
||||||
statusText = await git(cwd, [
|
statusText = await git(
|
||||||
'status',
|
cwd,
|
||||||
'--porcelain=v2',
|
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
|
||||||
'--branch',
|
{ timeoutMs }
|
||||||
'-z',
|
);
|
||||||
'--untracked-files=normal',
|
|
||||||
'--ignore-submodules=dirty',
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||||
@@ -307,7 +307,7 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
|
|||||||
|
|
||||||
const safe = async (args: string[]): Promise<string> => {
|
const safe = async (args: string[]): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
return await git(cwd, args);
|
return await git(cwd, args, { timeoutMs });
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -415,10 +415,12 @@ async function singleFlight<T>(
|
|||||||
*/
|
*/
|
||||||
export async function getGitWorkspaceStatus(
|
export async function getGitWorkspaceStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
|
||||||
): Promise<GitWorkspaceStatus> {
|
): Promise<GitWorkspaceStatus> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
|
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () =>
|
||||||
|
collect(cwd, git, opts.timeoutMs)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
||||||
@@ -427,12 +429,12 @@ const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
|
|||||||
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
||||||
function enclosingRepoRoot(
|
function enclosingRepoRoot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
|
||||||
): Promise<RepoToplevel> {
|
): Promise<RepoToplevel> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||||
try {
|
try {
|
||||||
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
|
const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
|
||||||
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
@@ -451,6 +453,16 @@ const DISCOVERY_MAX_DEPTH = 2;
|
|||||||
const DISCOVERY_MAX_ENTRIES = 300;
|
const DISCOVERY_MAX_ENTRIES = 300;
|
||||||
/** Repositories reported for one workspace. */
|
/** Repositories reported for one workspace. */
|
||||||
export const MAX_REPOS = 12;
|
export const MAX_REPOS = 12;
|
||||||
|
/** The most repositories a caller may ask for: each one costs several git processes per poll. */
|
||||||
|
export const MAX_REPOS_LIMIT = 50;
|
||||||
|
|
||||||
|
/** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
|
||||||
|
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
// An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
|
||||||
|
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||||||
|
}
|
||||||
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
||||||
const DISCOVERY_TTL_MS = 30_000;
|
const DISCOVERY_TTL_MS = 30_000;
|
||||||
/** Folders that are never worth descending into when looking for projects. */
|
/** Folders that are never worth descending into when looking for projects. */
|
||||||
@@ -472,8 +484,10 @@ export interface GitWorkspaceOverview {
|
|||||||
reason?: 'remote' | 'docker';
|
reason?: 'remote' | 'docker';
|
||||||
error?: string;
|
error?: string;
|
||||||
repos: GitRepoEntry[];
|
repos: GitRepoEntry[];
|
||||||
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
|
/** More than `repoLimit` repositories were found; only the first are reported. */
|
||||||
reposTruncated: boolean;
|
reposTruncated: boolean;
|
||||||
|
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
|
||||||
|
repoLimit?: number;
|
||||||
checkedAt: number;
|
checkedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +567,8 @@ async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] |
|
|||||||
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||||
export async function discoverChildRepos(
|
export async function discoverChildRepos(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
excludeRealRoots: string[] = []
|
excludeRealRoots: string[] = [],
|
||||||
|
maxRepos: number = MAX_REPOS
|
||||||
): Promise<{ dirs: string[]; truncated: boolean }> {
|
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let level = [cwd];
|
let level = [cwd];
|
||||||
@@ -576,7 +591,7 @@ export async function discoverChildRepos(
|
|||||||
}
|
}
|
||||||
level = next;
|
level = next;
|
||||||
}
|
}
|
||||||
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
|
return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||||
@@ -602,13 +617,28 @@ export interface GitOverviewOptions {
|
|||||||
home?: string;
|
home?: string;
|
||||||
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||||
dockerWorkspaces?: string[];
|
dockerWorkspaces?: string[];
|
||||||
|
/** How many repositories to report below a folder that is not itself a repository (1 to `MAX_REPOS_LIMIT`, default `MAX_REPOS`). */
|
||||||
|
maxRepos?: number;
|
||||||
|
/** How long one git command may run, in ms (`MIN_GIT_TIMEOUT_MS` to `MAX_GIT_TIMEOUT_MS`, default `DEFAULT_GIT_TIMEOUT_MS`). */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The repository limit and git timeout an overview was computed with, from untrusted options. */
|
||||||
|
export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: unknown }): {
|
||||||
|
maxRepos: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
|
||||||
|
timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceRepos =
|
type WorkspaceRepos =
|
||||||
| { kind: 'docker' }
|
| { kind: 'docker' }
|
||||||
| { kind: 'error'; error: string }
|
| { kind: 'error'; error: string }
|
||||||
| { kind: 'enclosing'; root: string }
|
| { kind: 'enclosing'; root: string }
|
||||||
| { kind: 'children'; dirs: string[]; truncated: boolean };
|
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||||
@@ -617,6 +647,7 @@ type WorkspaceRepos =
|
|||||||
*/
|
*/
|
||||||
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||||
const now = opts.now ?? Date.now;
|
const now = opts.now ?? Date.now;
|
||||||
|
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||||
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
||||||
// could carry config (a clean filter) that runs on the host.
|
// could carry config (a clean filter) that runs on the host.
|
||||||
@@ -624,7 +655,7 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
|
|||||||
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
||||||
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
||||||
// repositories below.
|
// repositories below.
|
||||||
const top = await enclosingRepoRoot(cwd, opts);
|
const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
|
||||||
if (top.state === 'error') return { kind: 'error', error: top.error };
|
if (top.state === 'error') return { kind: 'error', error: top.error };
|
||||||
if (top.state === 'ok') {
|
if (top.state === 'ok') {
|
||||||
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
||||||
@@ -633,18 +664,20 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not inside a repository of this workspace: look below for projects.
|
// Not inside a repository of this workspace: look below for projects.
|
||||||
const hit = discoveryCache.get(cwd);
|
// Keyed by the limit too: a list cut at 12 must not answer a request for 30.
|
||||||
|
const discoveryKey = `${cwd}\0${maxRepos}`;
|
||||||
|
const hit = discoveryCache.get(discoveryKey);
|
||||||
let found: { dirs: string[]; truncated: boolean };
|
let found: { dirs: string[]; truncated: boolean };
|
||||||
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||||
else {
|
else {
|
||||||
found = await discoverChildRepos(cwd, dockerRoots);
|
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
|
||||||
discoveryCache.set(cwd, { at: now(), value: found });
|
discoveryCache.set(discoveryKey, { at: now(), value: found });
|
||||||
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
||||||
}
|
}
|
||||||
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
||||||
return { kind: 'children', dirs, truncated: found.truncated };
|
return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,7 +692,7 @@ export async function getGitWorkspaceOverview(
|
|||||||
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||||
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||||
if (where.kind === 'enclosing') {
|
if (where.kind === 'enclosing') {
|
||||||
const primary = await getGitWorkspaceStatus(cwd, opts);
|
const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
|
||||||
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||||
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||||
const root = primary.repoRoot ?? where.root;
|
const root = primary.repoRoot ?? where.root;
|
||||||
@@ -671,14 +704,21 @@ export async function getGitWorkspaceOverview(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
|
const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
|
||||||
|
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
|
||||||
|
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
|
||||||
|
);
|
||||||
const repos: GitRepoEntry[] = [];
|
const repos: GitRepoEntry[] = [];
|
||||||
where.dirs.forEach((dir, i) => {
|
where.dirs.forEach((dir, i) => {
|
||||||
const status = statuses[i];
|
const status = statuses[i];
|
||||||
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
// A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
|
||||||
|
// list with its error, so it is visible that something is not being reported; only a folder
|
||||||
|
// that turned out not to be a repository after all is left out.
|
||||||
|
if (status.state === 'ok' || status.state === 'error')
|
||||||
|
repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
||||||
});
|
});
|
||||||
if (!repos.length) return emptyOverview('not-a-repo');
|
if (!repos.length) return emptyOverview('not-a-repo');
|
||||||
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
|
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -726,7 +766,7 @@ export function isSafeRepoRelativePath(p: string): boolean {
|
|||||||
export async function getGitFileDiff(
|
export async function getGitFileDiff(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
file: { path: string; origPath?: string; kind: GitFileKind },
|
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||||
opts: { git?: GitRunner } = {}
|
opts: { git?: GitRunner; timeoutMs?: number } = {}
|
||||||
): Promise<GitFileDiff> {
|
): Promise<GitFileDiff> {
|
||||||
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||||
throw new Error('Invalid path');
|
throw new Error('Invalid path');
|
||||||
@@ -742,7 +782,7 @@ export async function getGitFileDiff(
|
|||||||
let out: string;
|
let out: string;
|
||||||
let cutShort = false;
|
let cutShort = false;
|
||||||
try {
|
try {
|
||||||
out = await git(repoRoot, args);
|
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: unknown; stdout?: unknown };
|
const e = err as { code?: unknown; stdout?: unknown };
|
||||||
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { basename, extname, relative } from 'node:path';
|
|||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||||
import { KeyedDebouncer } from './utils/index.js';
|
import { KeyedDebouncer } from './utils/index.js';
|
||||||
|
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -157,12 +158,15 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
|
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
|
||||||
// Ignore common heavy directories for performance
|
// Ignore common heavy directories for performance
|
||||||
ignored: (path: string) => {
|
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 (
|
if (
|
||||||
path.includes('/node_modules/') ||
|
path.includes('/node_modules/') ||
|
||||||
path.includes('/.git/') ||
|
path.includes('/.git/') ||
|
||||||
path.includes('/dist/') ||
|
path.includes('/dist/') ||
|
||||||
path.includes('/.next/')
|
path.includes('/.next/') ||
|
||||||
|
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
* does not understand) is never rewritten and nothing is ever removed. Same name with a
|
* 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.
|
* 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
|
* - 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.
|
* switch it on in every other CLI.
|
||||||
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
|
* - 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
|
* duplicate table) is never written, and a write is only made after the NEW text has been
|
||||||
@@ -284,6 +285,29 @@ function toOpencode(s: McpServer): Record<string, unknown> {
|
|||||||
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
|
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 {
|
interface JsonDialect {
|
||||||
/** Key holding the server table. */
|
/** Key holding the server table. */
|
||||||
key: string;
|
key: string;
|
||||||
@@ -291,12 +315,23 @@ interface JsonDialect {
|
|||||||
to(s: McpServer): Record<string, unknown> | null;
|
to(s: McpServer): Record<string, unknown> | null;
|
||||||
/** Top-level keys to seed when creating the file from nothing. */
|
/** Top-level keys to seed when creating the file from nothing. */
|
||||||
seed?: Record<string, unknown>;
|
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> = {
|
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||||
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
||||||
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
||||||
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
||||||
|
'copilot-json': {
|
||||||
|
key: 'mcpServers',
|
||||||
|
from: fromCopilot,
|
||||||
|
to: toCopilot,
|
||||||
|
disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
|
||||||
|
},
|
||||||
'opencode-json': {
|
'opencode-json': {
|
||||||
key: 'mcp',
|
key: 'mcp',
|
||||||
from: fromOpencode,
|
from: fromOpencode,
|
||||||
@@ -558,6 +593,32 @@ function resolveFile(
|
|||||||
return { file: join(dir, rel.path) };
|
return { file: join(dir, rel.path) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
let applying = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -611,6 +672,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parsed = parseConfig(s.t.format, await readText(s.file));
|
const parsed = parseConfig(s.t.format, await readText(s.file));
|
||||||
|
await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
|
||||||
s.servers = parsed.servers;
|
s.servers = parsed.servers;
|
||||||
s.names = parsed.names;
|
s.names = parsed.names;
|
||||||
s.res.servers = [...parsed.names];
|
s.res.servers = [...parsed.names];
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The config readers a CLI's registry entry may name for the model its
|
||||||
|
* session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives
|
||||||
|
* here, keyed by name, so no code branches on a CLI id (like the launcher profiles in
|
||||||
|
* config/cli-registry/profiles.ts).
|
||||||
|
*
|
||||||
|
* A reader answers the model the CLI's own config pins for one session, or null when
|
||||||
|
* it pins none or the answer is in any doubt. It must be read-only, bounded (no
|
||||||
|
* synchronous filesystem call, nothing that can wait on a dead mount) and must return
|
||||||
|
* the model id alone, never another config value.
|
||||||
|
*
|
||||||
|
* @module model-config-resolvers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelConfigResolverName } from './config/cli-registry/types.js';
|
||||||
|
import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js';
|
||||||
|
|
||||||
|
/** What a reader gets to know about the session. */
|
||||||
|
export interface ModelConfigContext {
|
||||||
|
/** The session's own launch config for its CLI (its `<Mode>Config`), if any. */
|
||||||
|
config: Record<string, unknown> | undefined;
|
||||||
|
/** The environment the session's CLI runs with (its own overrides, then the server's). */
|
||||||
|
env: (key: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
|
||||||
|
// dsh-TUI's route: the session's profile (else the one the launch boots, which the
|
||||||
|
// launch names from the server's own dsh home) read under the session's dsh home.
|
||||||
|
'deepseek-route': (ctx) =>
|
||||||
|
readDeepSeekRouteModel({
|
||||||
|
profile: ctx.config?.profile,
|
||||||
|
home: effectiveDshHome(ctx.env),
|
||||||
|
serverHome: effectiveDshHome((key) => process.env[key]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the named reader resolves for a session, or null.
|
||||||
|
*
|
||||||
|
* @param name a `configResolver` from the registry (schema-checked at load)
|
||||||
|
* @param ctx what the reader may know about the session
|
||||||
|
*/
|
||||||
|
export async function resolveConfigModel(
|
||||||
|
name: ModelConfigResolverName,
|
||||||
|
ctx: ModelConfigContext
|
||||||
|
): Promise<string | null> {
|
||||||
|
const resolver = RESOLVERS[name];
|
||||||
|
return resolver ? resolver(ctx) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Which model a session is running, as far as the server can know it
|
||||||
|
* (`SessionState.displayModel`, shown in the tile grid's and split pane's headers).
|
||||||
|
*
|
||||||
|
* Pure: the session feeds it what it has and publishes the answer through `toState()`.
|
||||||
|
*
|
||||||
|
* ## Sources, strongest first
|
||||||
|
*
|
||||||
|
* 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered
|
||||||
|
* by that endpoint's `modelId`, whatever alias the CLI itself prints.
|
||||||
|
* 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's
|
||||||
|
* statusLine exporter posts `model.display_name` on every render (it follows an
|
||||||
|
* in-session `/model`); a CLI whose registry entry declares
|
||||||
|
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
|
||||||
|
* probe already takes.
|
||||||
|
* 3. **config**: the model the CLI's own config pins for this session, read by the
|
||||||
|
* reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the
|
||||||
|
* dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not
|
||||||
|
* a report from the running CLI, so any report outranks it.
|
||||||
|
* 4. **launch**: the model the session was launched with (claude's `--model` or the
|
||||||
|
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
|
||||||
|
* asked for, not what was reported, so it only shows when nothing reported.
|
||||||
|
*
|
||||||
|
* Nothing known means no field at all: the header shows the harness logo alone, never a
|
||||||
|
* placeholder or a guess.
|
||||||
|
*
|
||||||
|
* ## Untrusted text
|
||||||
|
*
|
||||||
|
* A screen-read model is pane text, and a statusline payload is a POST body: both are
|
||||||
|
* stripped of escape sequences and control characters, whitespace-collapsed and capped
|
||||||
|
* here, and the browser renders the result with `textContent`.
|
||||||
|
*
|
||||||
|
* Tests: `test/session-display-model.test.ts`.
|
||||||
|
*
|
||||||
|
* @module session-display-model
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DisplayModel, DisplayModelSource } from './types/session.js';
|
||||||
|
import { stripAnsi } from './utils/index.js';
|
||||||
|
import { getCli } from './config/cli-registry/index.js';
|
||||||
|
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||||
|
|
||||||
|
/** Longest model name published (the header truncates long before this). */
|
||||||
|
export const MAX_DISPLAY_MODEL_CHARS = 64;
|
||||||
|
|
||||||
|
/** A report from the running CLI itself: the sources a restart may restore. */
|
||||||
|
export type ReportedModelSource = Extract<DisplayModelSource, 'statusline' | 'screen'>;
|
||||||
|
|
||||||
|
export interface ReportedModel {
|
||||||
|
model: string;
|
||||||
|
source: ReportedModelSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model name fit to publish, or undefined when nothing printable is left.
|
||||||
|
*
|
||||||
|
* @param raw anything; only a string can yield a name
|
||||||
|
*/
|
||||||
|
export function sanitizeModelName(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!clean) return undefined;
|
||||||
|
return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a footer field can show that is never the model. */
|
||||||
|
export interface ScreenModelRejects {
|
||||||
|
/** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */
|
||||||
|
rejectWords?: readonly string[];
|
||||||
|
/** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */
|
||||||
|
cwdBasename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern.
|
||||||
|
*
|
||||||
|
* Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern
|
||||||
|
* can anchor on the row above), which keeps the search below the transcript: the
|
||||||
|
* pattern itself must still anchor on chrome only that CLI draws.
|
||||||
|
*
|
||||||
|
* A footer whose model field is switched off shows its NEXT field where the model
|
||||||
|
* was, so the captured field is not taken when it is one of the CLI's declared
|
||||||
|
* non-model words (an effort level, a mode) or the session's own folder name, which a
|
||||||
|
* footer field equal to is the folder, never the model, whatever the CLI. Anything
|
||||||
|
* else the pattern captures is read as the model.
|
||||||
|
*
|
||||||
|
* @param paneText a plain `capture-pane -p` frame, or null when it could not be read
|
||||||
|
* @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model
|
||||||
|
* @param tailRows how many non-blank rows from the bottom the pattern sees
|
||||||
|
* @param rejects fields that are never the model (see {@link ScreenModelRejects})
|
||||||
|
* @returns the model, or undefined when the frame shows none
|
||||||
|
*/
|
||||||
|
export function readScreenModel(
|
||||||
|
paneText: string | null | undefined,
|
||||||
|
pattern: RegExp,
|
||||||
|
tailRows: number = 1,
|
||||||
|
rejects: ScreenModelRejects = {}
|
||||||
|
): string | undefined {
|
||||||
|
if (!paneText) return undefined;
|
||||||
|
const rows = stripAnsi(paneText)
|
||||||
|
.split('\n')
|
||||||
|
.map((row) => row.trimEnd())
|
||||||
|
.filter((row) => row !== '');
|
||||||
|
const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n');
|
||||||
|
// compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a
|
||||||
|
// stale lastIndex would make the same frame match every other call.
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
const match = pattern.exec(window);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const field = match[1] ?? '';
|
||||||
|
if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined;
|
||||||
|
if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined;
|
||||||
|
return sanitizeModelName(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a session was launched with, read the way its spawn reads it: where the
|
||||||
|
* model param lives is registry data (`capabilities.model` names the param, the entry's
|
||||||
|
* `legacyConfigField` the `<Mode>Config` object holding it, or the option bag itself for
|
||||||
|
* claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell,
|
||||||
|
* dsh) has none.
|
||||||
|
*
|
||||||
|
* @param mode the session's CLI id
|
||||||
|
* @param bag the session's launch option bag (`model`, `codexConfig`, ...)
|
||||||
|
*/
|
||||||
|
export function launchModelFor(mode: string, bag: Record<string, unknown>): string | undefined {
|
||||||
|
const entry = getCli(mode);
|
||||||
|
const model = entry?.capabilities.model;
|
||||||
|
if (!entry || !model || model.source === 'none') return undefined;
|
||||||
|
const param = model.param ?? 'model';
|
||||||
|
const key = entry.launch.legacyConfigAliases?.[param] ?? param;
|
||||||
|
const value = legacyConfigForMode(mode, bag)?.[key];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persisted `displayModel` of a previous run, when it was a report from the CLI
|
||||||
|
* itself: a restart shows it until the next report replaces it. A custom-endpoint or
|
||||||
|
* launch answer is not restored, since the session derives those again by itself.
|
||||||
|
*/
|
||||||
|
export function restoredReportedModel(saved: unknown): ReportedModel | undefined {
|
||||||
|
if (!saved || typeof saved !== 'object') return undefined;
|
||||||
|
const { model, source } = saved as { model?: unknown; source?: unknown };
|
||||||
|
if (source !== 'statusline' && source !== 'screen') return undefined;
|
||||||
|
const name = sanitizeModelName(model);
|
||||||
|
return name ? { model: name, source } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a session header shows, and where it came from.
|
||||||
|
*
|
||||||
|
* @param input.customModelId the custom endpoint's model, when the session is pointed at one
|
||||||
|
* @param input.reported the newest report from the CLI itself
|
||||||
|
* @param input.configModel the model the CLI's config pins for the session
|
||||||
|
* @param input.launchModel the model the session was launched with
|
||||||
|
*/
|
||||||
|
export function resolveDisplayModel(input: {
|
||||||
|
customModelId?: string;
|
||||||
|
reported?: ReportedModel | null;
|
||||||
|
configModel?: string | null;
|
||||||
|
launchModel?: string;
|
||||||
|
}): DisplayModel | undefined {
|
||||||
|
const custom = sanitizeModelName(input.customModelId);
|
||||||
|
if (custom) return { model: custom, source: 'custom-endpoint' };
|
||||||
|
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
|
||||||
|
if (reported && input.reported) return { model: reported, source: input.reported.source };
|
||||||
|
const config = sanitizeModelName(input.configModel);
|
||||||
|
if (config) return { model: config, source: 'config' };
|
||||||
|
const launch = sanitizeModelName(input.launchModel);
|
||||||
|
if (launch) return { model: launch, source: 'launch' };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { basename } from 'node:path';
|
||||||
import { execSync, execFileSync } from 'node:child_process';
|
import { execSync, execFileSync } from 'node:child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
@@ -105,6 +106,7 @@ import {
|
|||||||
getClaudeBinaryPath,
|
getClaudeBinaryPath,
|
||||||
spawnPtyWithHelperRepair,
|
spawnPtyWithHelperRepair,
|
||||||
resolveLocalShell,
|
resolveLocalShell,
|
||||||
|
waitForProcessesExit,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_SIZE,
|
MAX_TERMINAL_BUFFER_SIZE,
|
||||||
@@ -137,7 +139,18 @@ import {
|
|||||||
sanitizeAttachmentHistory,
|
sanitizeAttachmentHistory,
|
||||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||||
} from './session-attachment-history.js';
|
} from './session-attachment-history.js';
|
||||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||||
|
import { resolveConfigModel } from './model-config-resolvers.js';
|
||||||
|
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||||
|
import {
|
||||||
|
launchModelFor,
|
||||||
|
readScreenModel,
|
||||||
|
resolveDisplayModel,
|
||||||
|
restoredReportedModel,
|
||||||
|
sanitizeModelName,
|
||||||
|
type ReportedModel,
|
||||||
|
type ReportedModelSource,
|
||||||
|
} from './session-display-model.js';
|
||||||
|
|
||||||
export type { BackgroundTask } from './task-tracker.js';
|
export type { BackgroundTask } from './task-tracker.js';
|
||||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||||
@@ -167,7 +180,10 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
|
|||||||
|
|
||||||
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||||
|
|
||||||
/** Graceful shutdown delay when stopping session (100ms) */
|
/**
|
||||||
|
* Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
|
||||||
|
* session. A deadline, not a sleep: stop() moves on as soon as it has exited.
|
||||||
|
*/
|
||||||
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||||
|
|
||||||
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
||||||
@@ -265,10 +281,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
|
|||||||
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
||||||
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||||
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||||
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
|
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
|
||||||
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
|
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
|
||||||
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
|
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
|
||||||
* with the replay-side strip in session-routes.ts.
|
* session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every
|
||||||
|
* stock CLI in test/claude-scrollback-strip.test.ts.
|
||||||
*
|
*
|
||||||
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
|
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
|
||||||
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
||||||
@@ -288,8 +305,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
||||||
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
|
* and the mouse-tracking DECSETs alone. Applies to every mode that is neither
|
||||||
* excludes, but ONLY when the session is tmux-backed (`useMux`).
|
* `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode),
|
||||||
|
* so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed
|
||||||
|
* (`useMux`).
|
||||||
*
|
*
|
||||||
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
|
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
|
||||||
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
||||||
@@ -314,7 +333,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
|||||||
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
||||||
*/
|
*/
|
||||||
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
||||||
return useMux && !isAltScreenStripMode(mode);
|
if (!useMux) return false;
|
||||||
|
const altScreen = getCli(mode)?.capabilities.altScreen;
|
||||||
|
return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone:
|
||||||
|
* `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI.
|
||||||
|
*
|
||||||
|
* Why this exists (opencode, measured 2026-09-16): the TUI enables tracking
|
||||||
|
* DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs
|
||||||
|
* straight through to the tmux client, and the browser's xterm obeyed them —
|
||||||
|
* `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI
|
||||||
|
* instead of selecting locally. "Mark text, copy on select" silently did nothing
|
||||||
|
* (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and
|
||||||
|
* the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also
|
||||||
|
* ended sessions. Stripping at the source keeps xterm in selection mode; clicks
|
||||||
|
* still reach the CLI through the browser's hand-encoded tap, which this strip
|
||||||
|
* publishes as `cliMouseTracking` (`_recordStrippedMouseMode`).
|
||||||
|
*
|
||||||
|
* Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY
|
||||||
|
* fallback the program's own DECSETs really do reach xterm and must be honoured.
|
||||||
|
*/
|
||||||
|
export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean {
|
||||||
|
return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||||
@@ -550,6 +593,27 @@ export class Session extends EventEmitter {
|
|||||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/**
|
||||||
|
* The newest model the running CLI reported for itself (its statusline, or its own
|
||||||
|
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
|
||||||
|
* (src/session-display-model.ts). Persisted through `toState()` and restored after a
|
||||||
|
* restart, so an idle session keeps naming its model until the next report.
|
||||||
|
*/
|
||||||
|
private _reportedModel: ReportedModel | null = null;
|
||||||
|
/**
|
||||||
|
* The model the CLI's own config pins for this session (`modelDetect.configResolver`),
|
||||||
|
* read at each pane start, attach or relaunch; null when it pins none. Below any
|
||||||
|
* report from the running CLI in `displayModel`. Not persisted: the next start reads it.
|
||||||
|
*/
|
||||||
|
private _configModel: string | null = null;
|
||||||
|
/** Bumped per config read, so a read that lands after a newer one is dropped. */
|
||||||
|
private _configModelGen = 0;
|
||||||
|
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
|
||||||
|
private _modelLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
|
||||||
|
private _modelLineRows = 1;
|
||||||
|
/** Resolved with the pattern above: the fields it shows that are never the model. */
|
||||||
|
private _modelRejectWords: readonly string[] = [];
|
||||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||||
@@ -807,6 +871,8 @@ export class Session extends EventEmitter {
|
|||||||
claudeSessionChain?: string[];
|
claudeSessionChain?: string[];
|
||||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||||
paneExit?: PaneExit;
|
paneExit?: PaneExit;
|
||||||
|
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||||
|
displayModel?: DisplayModel;
|
||||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||||
discoveredMuxSession?: boolean;
|
discoveredMuxSession?: boolean;
|
||||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||||
@@ -974,6 +1040,7 @@ export class Session extends EventEmitter {
|
|||||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||||
this.setPaneExit(config.paneExit);
|
this.setPaneExit(config.paneExit);
|
||||||
|
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||||
// both the id and the saved parent come from disk.
|
// both the id and the saved parent come from disk.
|
||||||
@@ -1267,6 +1334,9 @@ export class Session extends EventEmitter {
|
|||||||
} finally {
|
} finally {
|
||||||
this._paneLifecycleOps--;
|
this._paneLifecycleOps--;
|
||||||
this._paneStartedAt = Date.now();
|
this._paneStartedAt = Date.now();
|
||||||
|
// A start, attach or relaunch is when the CLI read its config, so it is when
|
||||||
|
// the model that config pins is read here too.
|
||||||
|
this._refreshConfigModel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1316,7 +1386,7 @@ export class Session extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* True when this session's PTY is a tmux client rather than the program itself.
|
* True when this session's PTY is a tmux client rather than the program itself.
|
||||||
* Read by the replay-side alt-screen strip, which must apply the same
|
* Read by the replay-side alt-screen strip, which must apply the same
|
||||||
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode).
|
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode, isMuxMouseStripMode).
|
||||||
*/
|
*/
|
||||||
get usesMux(): boolean {
|
get usesMux(): boolean {
|
||||||
return this._useMux;
|
return this._useMux;
|
||||||
@@ -1858,6 +1928,7 @@ export class Session extends EventEmitter {
|
|||||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
advisorModel: this._advisorModel,
|
advisorModel: this._advisorModel,
|
||||||
customModel: this.customModel,
|
customModel: this.customModel,
|
||||||
|
displayModel: this.displayModel,
|
||||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||||
@@ -2489,14 +2560,21 @@ export class Session extends EventEmitter {
|
|||||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||||
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
||||||
//
|
//
|
||||||
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
|
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
|
||||||
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
// when it is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
||||||
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
||||||
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
||||||
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
||||||
|
//
|
||||||
|
// The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI:
|
||||||
|
// it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking
|
||||||
|
// reaches xterm and every drag becomes a mouse report instead of a text
|
||||||
|
// selection, which is what killed mark-and-copy in opencode — while 3J stays,
|
||||||
|
// because a TUI is not a `clear` consumer.
|
||||||
const fullStrip = isAltScreenStripMode(this.mode);
|
const fullStrip = isAltScreenStripMode(this.mode);
|
||||||
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux);
|
||||||
if (fullStrip || altOnlyStrip) {
|
const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
||||||
|
if (fullStrip || mouseStrip || altOnlyStrip) {
|
||||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||||
@@ -2515,14 +2593,18 @@ export class Session extends EventEmitter {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
||||||
if (fullStrip) {
|
if (fullStrip) {
|
||||||
data = data
|
// eslint-disable-next-line no-control-regex
|
||||||
|
data = data.replace(/\x1b\[3J/g, '');
|
||||||
|
}
|
||||||
|
if (fullStrip || mouseStrip) {
|
||||||
|
data = data.replace(
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\x1b\[3J/g, '')
|
/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
|
||||||
// eslint-disable-next-line no-control-regex
|
(seq) => {
|
||||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
|
|
||||||
this._recordStrippedMouseMode(seq);
|
this._recordStrippedMouseMode(seq);
|
||||||
return '';
|
return '';
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2743,19 +2825,14 @@ export class Session extends EventEmitter {
|
|||||||
this.id;
|
this.id;
|
||||||
|
|
||||||
// For NEW mux sessions: wait for readiness then clean buffer
|
// For NEW mux sessions: wait for readiness then clean buffer
|
||||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
// For RESTORED mux sessions: leave the buffer alone - client will fetch it on tab switch
|
||||||
if (!isRestored) {
|
if (!isRestored) {
|
||||||
if (isExternalCliMode(this.mode)) {
|
if (isExternalCliMode(this.mode)) {
|
||||||
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
||||||
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
||||||
// Don't clear the buffer — the TUI's initial render IS the useful content.
|
// Don't clear the buffer — the TUI's initial render IS the useful content.
|
||||||
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
|
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
|
||||||
this._promptCheckTimeout = setTimeout(() => {
|
this._armPaneSettle(false);
|
||||||
this._promptCheckTimeout = null;
|
|
||||||
if (this._isStopped) return;
|
|
||||||
this._status = 'idle';
|
|
||||||
this.emit('needsRefresh');
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
} else {
|
||||||
// Claude mode: wait for ❯ prompt
|
// Claude mode: wait for ❯ prompt
|
||||||
this._promptCheckInterval = setInterval(() => {
|
this._promptCheckInterval = setInterval(() => {
|
||||||
@@ -2787,6 +2864,8 @@ export class Session extends EventEmitter {
|
|||||||
this._promptCheckTimeout = null;
|
this._promptCheckTimeout = null;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this._armPaneSettle(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||||
@@ -3134,9 +3213,130 @@ export class Session extends EventEmitter {
|
|||||||
this._lastPaneProbeWorking =
|
this._lastPaneProbeWorking =
|
||||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||||
this._readWatching(text);
|
this._readWatching(text);
|
||||||
|
this._readScreenModel(text);
|
||||||
return this._lastPaneProbeWorking;
|
return this._lastPaneProbeWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the model the CLI's own footer names off the same capture, for a CLI whose
|
||||||
|
* registry entry declares `capabilities.modelDetect`.
|
||||||
|
*
|
||||||
|
* Unlike `_readWatching`, a capture that could not be read, or a footer the pattern
|
||||||
|
* does not find (a popup covering it, a footer turned off), KEEPS the last model. The
|
||||||
|
* two are not symmetric: background work ends and its badge must go, while a model does
|
||||||
|
* not stop running because something was drawn over the row that names it.
|
||||||
|
*/
|
||||||
|
private _readScreenModel(paneText: string | null): void {
|
||||||
|
if (paneText === null) return;
|
||||||
|
const pattern = this._modelLinePattern();
|
||||||
|
if (!pattern) return;
|
||||||
|
const model = readScreenModel(paneText, pattern, this._modelLineRows, {
|
||||||
|
rejectWords: this._modelRejectWords,
|
||||||
|
// A footer field equal to the folder this session runs in is the folder, never the
|
||||||
|
// model: the generic half of the rule, for every CLI.
|
||||||
|
cwdBasename: basename(this.workingDir),
|
||||||
|
});
|
||||||
|
if (model) this.noteReportedModel('screen', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex reading this CLI's model off its footer, or null for a CLI that declares
|
||||||
|
* none. Compiled once per session through `compileVersionRegex()` (null, never a throw,
|
||||||
|
* for a pattern it refuses), like the working- and watching-line patterns.
|
||||||
|
*/
|
||||||
|
private _modelLinePattern(): RegExp | null {
|
||||||
|
if (this._modelLineRe === undefined) {
|
||||||
|
const detect = getCli(this.mode)?.capabilities.modelDetect;
|
||||||
|
this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null;
|
||||||
|
this._modelLineRows = detect?.screenLines ?? 1;
|
||||||
|
this._modelRejectWords = detect?.rejectWords ?? [];
|
||||||
|
}
|
||||||
|
return this._modelLineRe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a model the running CLI reported for itself: its statusline (claude's
|
||||||
|
* exporter, via `POST /api/status-telemetry`) or its own footer. The newest report
|
||||||
|
* wins whatever its source. An empty or unprintable report changes nothing.
|
||||||
|
*
|
||||||
|
* @returns true when the reported model changed (and `displayModelChanged` was emitted)
|
||||||
|
*/
|
||||||
|
noteReportedModel(source: ReportedModelSource, raw: unknown): boolean {
|
||||||
|
const model = sanitizeModelName(raw);
|
||||||
|
if (!model) return false;
|
||||||
|
if (this._reportedModel?.model === model && this._reportedModel.source === source) return false;
|
||||||
|
this._reportedModel = { model, source };
|
||||||
|
// The status does not change with it, so it needs a broadcast (and a persist) of its own.
|
||||||
|
this.emit('displayModelChanged');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model this session runs as far as the server knows, and where that came from:
|
||||||
|
* the custom endpoint's model, else the newest report from the CLI, else the launch
|
||||||
|
* model (src/session-display-model.ts). Undefined when none is known.
|
||||||
|
*/
|
||||||
|
get displayModel(): DisplayModel | undefined {
|
||||||
|
return resolveDisplayModel({
|
||||||
|
customModelId: this._customModel?.modelId,
|
||||||
|
reported: this._reportedModel,
|
||||||
|
configModel: this._configModel,
|
||||||
|
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same option bag the spawn reads its launch params from: `model` at the top for
|
||||||
|
* claude (the `--model` or app-wide default it was created with; inert for every other
|
||||||
|
* CLI, which is why it is not handed over for them), each other CLI's own
|
||||||
|
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
|
||||||
|
*/
|
||||||
|
private _launchOptionBag(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
|
antigravityConfig: this._antigravityConfig,
|
||||||
|
piConfig: this._piConfig,
|
||||||
|
grokConfig: this._grokConfig,
|
||||||
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
|
ompConfig: this._ompConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the model this session's CLI config pins, with the reader its registry entry
|
||||||
|
* names (`capabilities.modelDetect.configResolver`), and announce a change. Async and
|
||||||
|
* bounded (the reader probes before it reads); a read that lands after a newer one,
|
||||||
|
* or after the session stopped, is dropped. A remote or docker session's CLI reads its
|
||||||
|
* config on another machine or in its container, so nothing local is read for it.
|
||||||
|
*/
|
||||||
|
private _refreshConfigModel(): void {
|
||||||
|
const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver;
|
||||||
|
if (!name || this._remote || this._docker) return;
|
||||||
|
const gen = ++this._configModelGen;
|
||||||
|
const overrides = this._envOverrides;
|
||||||
|
resolveConfigModel(name, {
|
||||||
|
config: legacyConfigForMode(this.mode, this._launchOptionBag()),
|
||||||
|
// The session's own env first (already clamped for a non-granted owner), then the
|
||||||
|
// server's: what the pane's CLI inherits.
|
||||||
|
env: (key) => overrides?.[key] ?? process.env[key],
|
||||||
|
}).then(
|
||||||
|
(model) => {
|
||||||
|
if (gen !== this._configModelGen || this._isStopped) return;
|
||||||
|
// Sanitized where it is published (resolveDisplayModel), like every source.
|
||||||
|
const next = model || null;
|
||||||
|
if (next === this._configModel) return;
|
||||||
|
this._configModel = next;
|
||||||
|
this.emit('displayModelChanged');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
/* A reader answers null on doubt and never throws; a throw changes nothing. */
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the background-work chip off the same capture the working probe just took.
|
* Read the background-work chip off the same capture the working probe just took.
|
||||||
*
|
*
|
||||||
@@ -3272,14 +3472,74 @@ export class Session extends EventEmitter {
|
|||||||
// 1. Claude was working and is now at prompt (normal case)
|
// 1. Claude was working and is now at prompt (normal case)
|
||||||
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
||||||
const wasWorking = this._isWorking;
|
const wasWorking = this._isWorking;
|
||||||
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
if (wasWorking || this._status === 'busy') this._concludeIdle(wasWorking);
|
||||||
if (wasWorking || isInitialReady) {
|
}
|
||||||
this._isWorking = false;
|
|
||||||
this._status = 'idle';
|
/**
|
||||||
this._lastPromptTime = Date.now();
|
* The one place a pane is concluded idle: status, working flag and prompt stamp
|
||||||
if (wasWorking) this._maybeCaptureOmpSessionId();
|
* change together, and the change is ANNOUNCED. The `idle` event is what the web
|
||||||
this.emit('idle');
|
* server turns into `session:idle` plus a state broadcast, so a path that flips
|
||||||
}
|
* `_status` without it leaves every browser on the `busy` it was last sent. A fresh
|
||||||
|
* codex pane used to stay "working" in the UI for its whole life that way.
|
||||||
|
*
|
||||||
|
* @param turnEnded a real turn just finished (not a pane becoming ready at launch)
|
||||||
|
*/
|
||||||
|
private _concludeIdle(turnEnded: boolean): void {
|
||||||
|
this._isWorking = false;
|
||||||
|
this._status = 'idle';
|
||||||
|
this._lastPromptTime = Date.now();
|
||||||
|
// Only a finished turn proves omp has written its session file; a pane that is
|
||||||
|
// merely ready has nothing to resolve yet and could claim a neighbour's file.
|
||||||
|
if (turnEnded) this._maybeCaptureOmpSessionId();
|
||||||
|
this.emit('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the launch settle (`_settlePaneStartup`) for a pane `startInteractive()` just
|
||||||
|
* started or re-attached, when one applies:
|
||||||
|
* - a NEW pane of an external CLI, whose TUI has no ❯ for the Claude wait to find;
|
||||||
|
* - a RESTORED pane (Codeman restart, auto-reattach, tile Attach) of a CLI that
|
||||||
|
* declares no `capabilities.workDetect`. It is `busy` from `_resetBuffers()` like a
|
||||||
|
* new pane, and with no composer glyph to arm `_confirmIdle()` nothing else would
|
||||||
|
* ever settle it. A restored claude or codex pane is left to its glyph, which
|
||||||
|
* reads the screen first, so a restart in mid-turn is never called idle.
|
||||||
|
*/
|
||||||
|
private _armPaneSettle(isRestored: boolean): void {
|
||||||
|
const applies = isRestored ? !getCli(this.mode)?.capabilities.workDetect : isExternalCliMode(this.mode);
|
||||||
|
if (!applies) return;
|
||||||
|
const armedAt = Date.now();
|
||||||
|
this._promptCheckTimeout = setTimeout(() => this._settlePaneStartup(!isRestored, armedAt), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launch settle: 3 s after a NEW external-CLI pane spawned, or after ANY pane of a
|
||||||
|
* CLI without work detection was re-attached, its TUI is taken to have rendered. A pane
|
||||||
|
* still in its spawn-time `busy` is concluded idle (announced, see `_concludeIdle`),
|
||||||
|
* then, for a new pane, the browser is told to refetch the rendered screen.
|
||||||
|
*
|
||||||
|
* ⚠️ This used to set `_status = 'idle'` without an event. When the launch paint
|
||||||
|
* never tripped `_markWorking()`, the later `_confirmIdle()` found the status
|
||||||
|
* already idle and emitted nothing, so no browser ever learned the pane was ready.
|
||||||
|
*
|
||||||
|
* A pane is left to `_confirmIdle()` only when its CLI declares
|
||||||
|
* `capabilities.workDetect` (its composer glyph arms that confirmation, which reads
|
||||||
|
* the screen first) AND it is already working, or a prompt was submitted since the
|
||||||
|
* timer was armed: a turn started 2.9 s in is not marked working before the deferred
|
||||||
|
* parsers run, and an idle edge here would end a send-and-wait registered for it.
|
||||||
|
* For every other CLI this timer is the only thing that ever settles a fresh pane,
|
||||||
|
* so it settles it even if a stray spinner glyph in the launch paint latched
|
||||||
|
* `_isWorking`.
|
||||||
|
*
|
||||||
|
* @param refreshScreen emit `needsRefresh` (a new pane; an attach refetches by itself)
|
||||||
|
* @param armedAt when the timer was armed (a submit at or after it means a prompt)
|
||||||
|
*/
|
||||||
|
private _settlePaneStartup(refreshScreen: boolean, armedAt: number): void {
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
if (this._isStopped) return;
|
||||||
|
const busyWithTurn = this._isWorking || this.lastSubmitAt >= armedAt;
|
||||||
|
const leaveToConfirm = busyWithTurn && !!getCli(this.mode)?.capabilities.workDetect;
|
||||||
|
if (this._status === 'busy' && !leaveToConfirm) this._concludeIdle(false);
|
||||||
|
if (refreshScreen) this.emit('needsRefresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4267,7 +4527,7 @@ export class Session extends EventEmitter {
|
|||||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
||||||
// Only claude and codex declare a promptGlyph; the other eight modes would fall back
|
// Only claude, codex, pi, opencode, omp and gemini declare a promptGlyph; the other modes would fall back
|
||||||
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
|
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
|
||||||
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
|
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
|
||||||
// on screen for as long as the command runs, promptStillInComposer() reads that as
|
// on screen for as long as the command runs, promptStillInComposer() reads that as
|
||||||
@@ -4545,8 +4805,10 @@ export class Session extends EventEmitter {
|
|||||||
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give it a moment to terminate gracefully
|
// Give it a moment to terminate gracefully. For a tmux-backed session this
|
||||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
// is the attach client, gone within a few ms of SIGTERM, and this used to be
|
||||||
|
// a fixed 100ms sleep on every close.
|
||||||
|
if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
|
||||||
|
|
||||||
// Force kill with SIGKILL if still alive
|
// Force kill with SIGKILL if still alive
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -761,13 +761,35 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* by workingDir alone would kill subagents belonging to OTHER sessions.
|
* by workingDir alone would kill subagents belonging to OTHER sessions.
|
||||||
*/
|
*/
|
||||||
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
|
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
|
||||||
const subagents = this.getSubagentsForSession(workingDir);
|
const targets = this.getSubagentsForSession(workingDir).filter(
|
||||||
for (const agent of subagents) {
|
// Only kill subagents belonging to this specific session
|
||||||
if (agent.status === 'active' || agent.status === 'idle') {
|
(agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
|
||||||
// Only kill subagents belonging to this specific session
|
);
|
||||||
if (sessionId && agent.sessionId !== sessionId) continue;
|
if (targets.length === 0) return;
|
||||||
await this.killSubagent(agent.agentId);
|
|
||||||
|
// ONE process scan for the lot. This used to call killSubagent() per agent, and
|
||||||
|
// each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
|
||||||
|
// box with ~100 matching processes), so closing a session right after a workflow
|
||||||
|
// paid that once per recently active subagent. The match rules are
|
||||||
|
// findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
|
||||||
|
const pidMap = await this.getClaudePids();
|
||||||
|
const signalled = new Set<number>();
|
||||||
|
for (const agent of targets) {
|
||||||
|
// The liveness checker may have completed it while the scan ran.
|
||||||
|
if (agent.status !== 'active' && agent.status !== 'idle') continue;
|
||||||
|
for (const [pid, procInfo] of pidMap) {
|
||||||
|
if (signalled.has(pid)) continue;
|
||||||
|
if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
|
||||||
|
signalled.add(pid);
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited
|
||||||
|
}
|
||||||
|
break; // one process per agent, as killSubagent() does
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
this.markSubagentAsCompleted(agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,5 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
|
|||||||
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
|
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
|
||||||
- The session persists across disconnects — your work is safe.
|
- The session persists across disconnects — your work is safe.
|
||||||
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
|
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
|
||||||
|
- After creating a file, write out its full absolute path in your final reply, e.g. `/home/me/project/docs/report.md`. Codeman makes absolute paths in the terminal clickable and opens them in its file viewer; a relative path (`docs/report.md`), a `~/` path or a markdown link (`[report](...)`) cannot be clicked.
|
||||||
|
- If the `codeman` skill is available, use it to start other Codeman sessions as workers, send them prompts, wait for them to finish, read their output and clean them up. When asked to parallelize work and the skill is missing, tell the user they can install it with `codeman skill install`.
|
||||||
|
|||||||
@@ -94,7 +94,13 @@ import {
|
|||||||
type DockerMount,
|
type DockerMount,
|
||||||
type DockerSeedCopy,
|
type DockerSeedCopy,
|
||||||
} from './docker-hosts.js';
|
} from './docker-hosts.js';
|
||||||
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
|
import {
|
||||||
|
wrapWithNice,
|
||||||
|
SAFE_PATH_PATTERN,
|
||||||
|
resolveLocalShell,
|
||||||
|
loginShellArgs,
|
||||||
|
waitForProcessesExit,
|
||||||
|
} from './utils/index.js';
|
||||||
import type {
|
import type {
|
||||||
TerminalMultiplexer,
|
TerminalMultiplexer,
|
||||||
MuxSession,
|
MuxSession,
|
||||||
@@ -144,12 +150,18 @@ const TMUX_CREATION_WAIT_MS = 100;
|
|||||||
const GET_PID_MAX_RETRIES = 5;
|
const GET_PID_MAX_RETRIES = 5;
|
||||||
const GET_PID_RETRY_MS = 200;
|
const GET_PID_RETRY_MS = 200;
|
||||||
|
|
||||||
/** Delay after tmux kill command (200ms) */
|
/**
|
||||||
|
* How long killSession gives a pane's children to exit on SIGTERM before it
|
||||||
|
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
|
||||||
|
*/
|
||||||
const TMUX_KILL_WAIT_MS = 200;
|
const TMUX_KILL_WAIT_MS = 200;
|
||||||
|
|
||||||
/** Delay for graceful shutdown (100ms) */
|
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
|
||||||
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||||
|
|
||||||
|
/** How long killSession waits for every process it signalled to be gone before it warns. */
|
||||||
|
const KILL_VERIFY_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
/** Default stats collection interval (2 seconds) */
|
/** Default stats collection interval (2 seconds) */
|
||||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
@@ -2350,6 +2362,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link getPanePid} without blocking the event loop, for the kill path: a close
|
||||||
|
* must not stall every other session's I/O while tmux answers.
|
||||||
|
*/
|
||||||
|
private async getPanePidAsync(muxName: string): Promise<number | null> {
|
||||||
|
if (IS_TEST_MODE) return 99999;
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const pid = parseInt(stdout.trim(), 10);
|
||||||
|
return Number.isNaN(pid) ? null : pid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a tmux session exists.
|
* Check if a tmux session exists.
|
||||||
*/
|
*/
|
||||||
@@ -2604,7 +2638,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a process is still alive
|
// Check if a process is still alive. Signal decisions only: an unreaped zombie
|
||||||
|
// counts here, so its process group still gets the SIGKILL below. The WAITS use
|
||||||
|
// waitForProcessesExit(), which counts a zombie as exited.
|
||||||
private isProcessAlive(pid: number): boolean {
|
private isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
@@ -2614,20 +2650,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all PIDs are dead, with retry
|
// Verify all PIDs are dead, returning as soon as they are
|
||||||
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
||||||
const startTime = Date.now();
|
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
|
||||||
const checkInterval = 100;
|
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitMs) {
|
|
||||||
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
|
||||||
if (aliveCount === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
||||||
}
|
|
||||||
|
|
||||||
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
|
||||||
if (stillAlive.length > 0) {
|
if (stillAlive.length > 0) {
|
||||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -2685,7 +2710,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (isValidMuxName(session.muxName)) {
|
if (isValidMuxName(session.muxName)) {
|
||||||
try {
|
try {
|
||||||
// Local socket only — detaches the remote session by killing the local ssh pane.
|
// Local socket only — detaches the remote session by killing the local ssh pane.
|
||||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2702,7 +2727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current PID (may have changed)
|
// Get current PID (may have changed)
|
||||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
|
||||||
|
|
||||||
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
||||||
|
|
||||||
@@ -2724,7 +2749,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
// Most children are gone within a few ms; the re-scan below still runs, to
|
||||||
|
// catch anything spawned since the first one.
|
||||||
|
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
|
||||||
|
|
||||||
childPids = await this.getChildPidsFresh(currentPid);
|
childPids = await this.getChildPidsFresh(currentPid);
|
||||||
for (const childPid of childPids) {
|
for (const childPid of childPids) {
|
||||||
@@ -2742,7 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (this.isProcessAlive(currentPid)) {
|
if (this.isProcessAlive(currentPid)) {
|
||||||
try {
|
try {
|
||||||
process.kill(-currentPid, 'SIGTERM');
|
process.kill(-currentPid, 'SIGTERM');
|
||||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
|
||||||
if (this.isProcessAlive(currentPid)) {
|
if (this.isProcessAlive(currentPid)) {
|
||||||
process.kill(-currentPid, 'SIGKILL');
|
process.kill(-currentPid, 'SIGKILL');
|
||||||
}
|
}
|
||||||
@@ -2751,10 +2778,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
|
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
|
||||||
|
// Async: tmux takes tens of ms to tear a session down, and execSync held the
|
||||||
|
// whole server for that long on every close.
|
||||||
if (isValidMuxName(session.muxName)) {
|
if (isValidMuxName(session.muxName)) {
|
||||||
try {
|
try {
|
||||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2798,7 +2827,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all processes are dead
|
// Verify all processes are dead
|
||||||
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
|
||||||
if (!allDead) {
|
if (!allDead) {
|
||||||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ import https from 'node:https';
|
|||||||
import { hostname as osHostname } from 'node:os';
|
import { hostname as osHostname } from 'node:os';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
|
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 { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
import { probeServer } from '../daemon-control.js';
|
import { probeServer } from '../daemon-control.js';
|
||||||
import { getErrorMessage } from '../types/api.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}`];
|
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// One credential reader for every client of the API (attach, tui, agent).
|
||||||
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
|
export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
|
||||||
* lines and `#` comments skipped, one layer of matching quotes stripped.
|
export type TuiCredentials = CodemanCredentials;
|
||||||
*/
|
|
||||||
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')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Degraded mode
|
// Degraded mode
|
||||||
|
|||||||
@@ -641,6 +641,24 @@ export interface CustomModelSelection {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a session's {@link DisplayModel} came from (src/session-display-model.ts):
|
||||||
|
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
|
||||||
|
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
|
||||||
|
* - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch.
|
||||||
|
* - `config`: what the CLI's own config pins for this session
|
||||||
|
* (`capabilities.modelDetect.configResolver`), while its screen names none.
|
||||||
|
* - `launch`: what the session was launched with; nothing has reported since.
|
||||||
|
*/
|
||||||
|
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch';
|
||||||
|
|
||||||
|
/** The model a session runs as far as the server knows, for a session header. */
|
||||||
|
export interface DisplayModel {
|
||||||
|
/** Display text: sanitized (no control characters) and at most 64 characters. */
|
||||||
|
model: string;
|
||||||
|
source: DisplayModelSource;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full custom-model selection a session keeps: the public selection plus the
|
* The full custom-model selection a session keeps: the public selection plus the
|
||||||
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
||||||
@@ -794,7 +812,8 @@ export interface SessionState {
|
|||||||
/**
|
/**
|
||||||
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
||||||
* by the server on its way out of the stream (those sequences are stripped for
|
* by the server on its way out of the stream (those sequences are stripped for
|
||||||
* claude/codex/gemini, so the browser can never see them itself). The browser
|
* the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode
|
||||||
|
* under tmux, so the browser can never see them itself). The browser
|
||||||
* hand-encodes a click report ONLY when this is true; without it, every click
|
* hand-encodes a click report ONLY when this is true; without it, every click
|
||||||
* sent mouse reports to a CLI that never asked for them.
|
* sent mouse reports to a CLI that never asked for them.
|
||||||
*/
|
*/
|
||||||
@@ -847,6 +866,16 @@ export interface SessionState {
|
|||||||
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
||||||
*/
|
*/
|
||||||
customModel?: CustomModelSelection;
|
customModel?: CustomModelSelection;
|
||||||
|
/**
|
||||||
|
* The model this session runs, as far as the server knows it, and where that came from
|
||||||
|
* (src/session-display-model.ts): the custom endpoint's model, else the newest report
|
||||||
|
* from the CLI itself (statusline or its own footer), else the model its config pins,
|
||||||
|
* else the launch model. Absent when
|
||||||
|
* none is known; a session header then shows the harness alone. Untrusted display text
|
||||||
|
* (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen`
|
||||||
|
* value is restored after a restart until the next report replaces it.
|
||||||
|
*/
|
||||||
|
displayModel?: DisplayModel;
|
||||||
/** Sanitized per-session attachment history. */
|
/** Sanitized per-session attachment history. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
|
|||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'presentation'
|
| 'presentation'
|
||||||
|
| 'spreadsheet'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'text';
|
| 'text';
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
|||||||
/** Profile directory names that are not profiles. */
|
/** Profile directory names that are not profiles. */
|
||||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||||
|
|
||||||
|
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
|
||||||
|
export function isProfileDirName(name: string): boolean {
|
||||||
|
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
|
||||||
|
}
|
||||||
|
|
||||||
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||||
const haystack = [name, ...bundles].join(' ');
|
const haystack = [name, ...bundles].join(' ');
|
||||||
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||||
@@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
|||||||
*/
|
*/
|
||||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile from its directory name and the text of its `package.json`, or null when
|
||||||
|
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
|
||||||
|
* same classification as the inventory below.
|
||||||
|
*/
|
||||||
|
export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null {
|
||||||
|
try {
|
||||||
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||||
const rawBundles = parsed?.dsh?.profile?.bundles;
|
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||||
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||||
@@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
|||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
|
||||||
.map((e) => e.name);
|
.map((e) => e.name);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
|||||||
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
||||||
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
||||||
export { StaleExpirationMap } from './stale-expiration-map.js';
|
export { StaleExpirationMap } from './stale-expiration-map.js';
|
||||||
|
export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
|
||||||
export {
|
export {
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Wait for processes to exit, and return as soon as they have.
|
||||||
|
*
|
||||||
|
* The session kill path used to sleep a FIXED interval after each signal (100 ms
|
||||||
|
* for the PTY client, 200 ms for the pane's children, 100 ms for the process
|
||||||
|
* group) and then verified in 100 ms steps. A process that was gone after 3 ms
|
||||||
|
* still cost the whole interval, so closing a tab spent most of its ~0.5 s in
|
||||||
|
* timers. This keeps every deadline the kill path had; it only stops waiting
|
||||||
|
* once there is nothing left to wait for.
|
||||||
|
*
|
||||||
|
* A zombie counts as exited. It holds nothing but its pid until the parent reaps
|
||||||
|
* it, and on the kill path that parent is the tmux server or the service
|
||||||
|
* manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
|
||||||
|
* zombie from a running process, so on Linux the state letter in
|
||||||
|
* `/proc/<pid>/stat` decides; without procfs `kill(pid, 0)` is the answer.
|
||||||
|
*
|
||||||
|
* @module utils/process-exit-wait
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
/** Poll step while waiting: an exit is noticed within about one frame. */
|
||||||
|
export const PROCESS_EXIT_POLL_MS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while `pid` names a process that has not exited. A pid we may not signal
|
||||||
|
* reads as not running, which is what the kill path's own check always did:
|
||||||
|
* there is nothing it could do about such a process anyway.
|
||||||
|
*/
|
||||||
|
export function isProcessRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.platform !== 'linux') return true;
|
||||||
|
let stat: string;
|
||||||
|
try {
|
||||||
|
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
// Gone between the two reads. Any other failure: trust kill(pid, 0).
|
||||||
|
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
|
||||||
|
}
|
||||||
|
// "<pid> (<comm>) <state> …": comm may hold spaces and parentheses itself,
|
||||||
|
// so the state is the field after the LAST ')'.
|
||||||
|
const close = stat.lastIndexOf(')');
|
||||||
|
const state = close === -1 ? '' : stat.charAt(close + 2);
|
||||||
|
return state !== 'Z' && state !== 'X';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitForExitOptions {
|
||||||
|
/** Give up after this long; the survivors are returned, never thrown. */
|
||||||
|
timeoutMs: number;
|
||||||
|
/** Poll step, {@link PROCESS_EXIT_POLL_MS} by default. */
|
||||||
|
pollMs?: number;
|
||||||
|
/** Liveness probe, {@link isProcessRunning} by default (injectable for tests). */
|
||||||
|
isRunning?: (pid: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve once every pid in `pids` has exited, or at the deadline with the ones
|
||||||
|
* that have not. Never rejects.
|
||||||
|
*/
|
||||||
|
export async function waitForProcessesExit(pids: readonly number[], options: WaitForExitOptions): Promise<number[]> {
|
||||||
|
const isRunning = options.isRunning ?? isProcessRunning;
|
||||||
|
const pollMs = Math.max(1, options.pollMs ?? PROCESS_EXIT_POLL_MS);
|
||||||
|
const deadline = Date.now() + options.timeoutMs;
|
||||||
|
let running = pids.filter((pid) => isRunning(pid));
|
||||||
|
while (running.length > 0) {
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
|
||||||
|
running = running.filter((pid) => isRunning(pid));
|
||||||
|
}
|
||||||
|
return running;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
|
||||||
|
*
|
||||||
|
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
|
||||||
|
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
|
||||||
|
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
|
||||||
|
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
|
||||||
|
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
|
||||||
|
* unchecked value onto the command line.
|
||||||
|
*
|
||||||
|
* Scope is the caller's decision: the create and quick-start routes apply it to local
|
||||||
|
* launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes
|
||||||
|
* a CLI's own config files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCli } from '../config/cli-registry/registry.js';
|
||||||
|
import { SettingsUpdateSchema } from './schemas.js';
|
||||||
|
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return `configs` with the launch defaults of `mode`'s registry entry filled into its
|
||||||
|
* legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed
|
||||||
|
* through untouched, and `configs` itself comes back unchanged (same object) when the entry
|
||||||
|
* declares no defaults, `customEndpoint` is set, or no setting names a value.
|
||||||
|
*/
|
||||||
|
export async function applyLaunchDefaults<T extends object>(
|
||||||
|
mode: string,
|
||||||
|
configs: T,
|
||||||
|
customEndpoint = false
|
||||||
|
): Promise<T> {
|
||||||
|
const entry = getCli(mode);
|
||||||
|
const declared = entry?.capabilities.launchDefaults;
|
||||||
|
const field = entry?.launch.legacyConfigField;
|
||||||
|
if (customEndpoint || !declared || field === undefined) return configs;
|
||||||
|
|
||||||
|
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'CLI launch defaults', {});
|
||||||
|
const aliases = entry.launch.legacyConfigAliases ?? {};
|
||||||
|
const current = (configs as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
for (const [param, settingKey] of Object.entries(declared)) {
|
||||||
|
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
|
||||||
|
// '' is the settings' "leave it to the CLI" value, the same as unset.
|
||||||
|
const value = parsed.success ? parsed.data || undefined : undefined;
|
||||||
|
const wireKey = aliases[param] ?? param;
|
||||||
|
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
|
||||||
|
}
|
||||||
|
if (Object.keys(defaults).length === 0) return configs;
|
||||||
|
return { ...configs, [field]: { ...current, ...defaults } };
|
||||||
|
}
|
||||||
@@ -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
|
* 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
|
* killMux=true session deletion, so long-lived sessions can fill disk under
|
||||||
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
|
* 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
|
* Conservative defaults — only files matching the `paste-` prefix are
|
||||||
* considered, and we lstat (not stat) so a planted symlink cannot escape the
|
* considered, and we lstat (not stat) so a planted symlink cannot escape the
|
||||||
* image dir.
|
* upload dir.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { realpathSync } from 'node:fs';
|
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';
|
import type { SessionPort } from './ports/index.js';
|
||||||
|
|
||||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
|
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(
|
export async function sweepPasteImagesOnce(
|
||||||
ctx: Pick<SessionPort, 'sessions'>,
|
ctx: Pick<SessionPort, 'sessions'>,
|
||||||
now: number = Date.now()
|
now: number = Date.now()
|
||||||
@@ -28,26 +102,27 @@ export async function sweepPasteImagesOnce(
|
|||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
for (const session of ctx.sessions.values()) {
|
for (const session of ctx.sessions.values()) {
|
||||||
const dir = join(session.workingDir, '.claude-images');
|
for (const dir of await uploadDirs(session)) {
|
||||||
let entries: string[];
|
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;
|
|
||||||
try {
|
try {
|
||||||
const st = await fs.lstat(p);
|
entries = await fs.readdir(dir);
|
||||||
if (!st.isFile()) continue;
|
|
||||||
if (st.mtimeMs < cutoff) {
|
|
||||||
await fs.unlink(p);
|
|
||||||
deleted += 1;
|
|
||||||
}
|
|
||||||
} catch {
|
} 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
|
* 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
|
* through a symlink matches, and the normalised path otherwise (a directory
|
||||||
* that no longer exists has nothing left to protect).
|
* 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 {
|
export interface PasteImageDirUser {
|
||||||
id: string;
|
id: string;
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
@@ -76,11 +151,11 @@ export interface PasteImageDirUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does another live session still use this working directory's paste-image
|
* Does another live session still use this working directory's upload dirs?
|
||||||
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
|
* Deleting a session removes them (`uploadDirs()`) recursively, and several
|
||||||
* and several sessions routinely share one case directory, so without this
|
* sessions routinely share one case directory, so without this check closing
|
||||||
* check closing one session deletes the pasted images a sibling in the same
|
* one session deletes the pasted images a sibling in the same case still
|
||||||
* case still refers to.
|
* refers to.
|
||||||
*
|
*
|
||||||
* Two kinds of sibling count as live:
|
* Two kinds of sibling count as live:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
|
||||||
* One picker per surface, plus themes that set all four to a matching look.
|
* 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
|
* 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
|
* behaves exactly as it did before this module existed. Opt in via App Settings
|
||||||
* → Appearance → Entrance Animations.
|
* → Animations.
|
||||||
*
|
*
|
||||||
* Four constraints shape the design:
|
* Four constraints shape the design:
|
||||||
*
|
*
|
||||||
@@ -34,13 +35,28 @@
|
|||||||
* once-per-id even though the POST response and the SSE event both call
|
* once-per-id even though the POST response and the SSE event both call
|
||||||
* `_onSessionCreated`.
|
* `_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` /
|
* 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`
|
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
|
||||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
|
||||||
* so styles can be compared without spawning real sessions or agents.
|
* 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
|
* @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)
|
* @dependency constants.js (escapeHtml)
|
||||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
* @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 = [
|
const TERM_ANIM_STYLES = [
|
||||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
{ 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: '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: '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 },
|
{ 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 },
|
{ 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. */
|
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||||
const BEAM_HOLD_MS = 360;
|
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. */
|
/** One-click combinations that read as a single look. */
|
||||||
const ANIM_THEMES = [
|
const ANIM_THEMES = [
|
||||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
{ 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' },
|
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
|
||||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
|
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
|
||||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
|
||||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
|
||||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
{ 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
|
* 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
|
* `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
|
* 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 →
|
* hook short-circuits on its first line. Opt in via App Settings → Animations,
|
||||||
* Entrance Animations, which persists to the localStorage keys below.
|
* which persists to the localStorage keys below.
|
||||||
*/
|
*/
|
||||||
const TAB_ANIM_DEFAULT = 'off';
|
const TAB_ANIM_DEFAULT = 'off';
|
||||||
const WIN_ANIM_DEFAULT = 'fly';
|
const WIN_ANIM_DEFAULT = 'fly';
|
||||||
const LINE_ANIM_DEFAULT = 'off';
|
const LINE_ANIM_DEFAULT = 'off';
|
||||||
const TERM_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;
|
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||||
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
|
|||||||
win: 'codeman:winAnim',
|
win: 'codeman:winAnim',
|
||||||
line: 'codeman:lineAnim',
|
line: 'codeman:lineAnim',
|
||||||
term: 'codeman:termAnim',
|
term: 'codeman:termAnim',
|
||||||
|
tile: 'codeman:tileAnim',
|
||||||
|
tileOrder: 'codeman:tileAnimOrder',
|
||||||
termSwitch: 'codeman:termAnimOnSwitch',
|
termSwitch: 'codeman:termAnimOnSwitch',
|
||||||
stagger: 'codeman:tabAnimStagger',
|
stagger: 'codeman:tabAnimStagger',
|
||||||
speed: 'codeman:tabAnimSpeed',
|
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.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.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 });
|
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.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||||
|
|
||||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { 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);
|
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. */
|
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||||
this._termAnimOnSwitch = !!on;
|
this._termAnimOnSwitch = !!on;
|
||||||
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.setWinAnimStyle(theme.win);
|
this.setWinAnimStyle(theme.win);
|
||||||
this.setLineAnimStyle(theme.line);
|
this.setLineAnimStyle(theme.line);
|
||||||
this.setTermAnimStyle(theme.term);
|
this.setTermAnimStyle(theme.term);
|
||||||
|
this.setTileAnimStyle(theme.tile);
|
||||||
this._syncEntranceAnimSetting?.();
|
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() {
|
currentAnimTheme() {
|
||||||
const match = ANIM_THEMES.find(
|
const current = this._currentAnimStyles();
|
||||||
(t) =>
|
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
|
||||||
t.tab === this._tabAnimStyle &&
|
|
||||||
t.win === this._winAnimStyle &&
|
|
||||||
t.line === this._lineAnimStyle &&
|
|
||||||
t.term === this._termAnimStyle
|
|
||||||
);
|
|
||||||
return match ? match.key : 'custom';
|
return match ? match.key : 'custom';
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── App Settings picker ───────────────────────────────────────────────────
|
// ── App Settings → Animations ─────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
||||||
// styles live in their own localStorage keys, so they stay per-device and never
|
// styles live in their own localStorage keys, so they stay per-device and never
|
||||||
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
_syncEntranceAnimSetting() {
|
_syncEntranceAnimSetting() {
|
||||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||||
if (!sel) return;
|
if (sel) {
|
||||||
sel.value = this.currentAnimTheme();
|
sel.value = this.currentAnimTheme();
|
||||||
if (!sel.dataset.bound) {
|
if (!sel.dataset.bound) {
|
||||||
sel.dataset.bound = '1';
|
sel.dataset.bound = '1';
|
||||||
sel.addEventListener('change', () => {
|
sel.addEventListener('change', () => {
|
||||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||||
else this.setAnimTheme(sel.value);
|
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 } = {}) {
|
setTabAnimStagger(ms, { persist = true } = {}) {
|
||||||
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_tileAnimDuration() {
|
||||||
|
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
/** 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) ───────────────
|
// ── Lab (compare styles without spawning sessions or agents) ───────────────
|
||||||
|
|
||||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||||
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</label>
|
</label>
|
||||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
${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>
|
</div>
|
||||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
<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="window">Window</button>
|
||||||
<button type="button" data-demo="all">All</button>
|
<button type="button" data-demo="all">All</button>
|
||||||
</div>
|
</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>
|
<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);
|
document.body.appendChild(panel);
|
||||||
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||||
|
else if (attr === 'tile') this.setTileAnimStyle(style);
|
||||||
else this.setLineAnimStyle(style);
|
else this.setLineAnimStyle(style);
|
||||||
this._syncAnimLab();
|
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) => {
|
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
||||||
this.setTermAnimOnSwitch(e.target.checked);
|
this.setTermAnimOnSwitch(e.target.checked);
|
||||||
});
|
});
|
||||||
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_syncAnimLab() {
|
_syncAnimLab() {
|
||||||
const panel = document.getElementById('animLab');
|
const panel = document.getElementById('animLab');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const current = {
|
const current = this._currentAnimStyles();
|
||||||
tab: this._tabAnimStyle,
|
|
||||||
win: this._winAnimStyle,
|
|
||||||
line: this._lineAnimStyle,
|
|
||||||
term: this._termAnimStyle,
|
|
||||||
};
|
|
||||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||||
});
|
});
|
||||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||||
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
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"]');
|
const check = panel.querySelector('input[data-check="termSwitch"]');
|
||||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||||
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return svg;
|
return svg;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
|
||||||
demoEntrance(what = 'all') {
|
demoEntrance(what = 'all') {
|
||||||
this._clearEntranceDemo();
|
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
|
// 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
|
// through the same entry point a real launch uses (bypassing the owed-id
|
||||||
// check, which only exists to keep background sessions from hijacking it).
|
// check, which only exists to keep background sessions from hijacking it).
|
||||||
@@ -692,6 +1139,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="tab-number">${base + i + 1}</span>
|
<span class="tab-number">${base + i + 1}</span>
|
||||||
<span class="tab-status idle" aria-hidden="true"></span>
|
<span class="tab-status idle" aria-hidden="true"></span>
|
||||||
<span class="tab-info"><span class="tab-name-row">
|
<span class="tab-info"><span class="tab-name-row">
|
||||||
|
<span class="tab-harness run-mode-dot claude" aria-hidden="true"></span>
|
||||||
<span class="tab-name">w${base + i + 1}-demo</span>
|
<span class="tab-name">w${base + i + 1}-demo</span>
|
||||||
</span></span>`;
|
</span></span>`;
|
||||||
container.appendChild(tab);
|
container.appendChild(tab);
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||||
this._gitStatusFetchedAt = Date.now();
|
this._gitStatusFetchedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
const query = new URLSearchParams(this.gitStatusLimits());
|
||||||
|
if (fresh) query.set('fresh', '1');
|
||||||
|
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
|
||||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many repositories to list below a folder that is not itself a repository, and how long one
|
||||||
|
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
|
||||||
|
*/
|
||||||
|
gitStatusLimits() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
const num = (v, min, max, fallback) => {
|
||||||
|
const n = Math.trunc(Number(v));
|
||||||
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
|
||||||
|
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||||
isGitStatusTree() {
|
isGitStatusTree() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let uncommitted = 0;
|
let uncommitted = 0;
|
||||||
let unpushed = 0;
|
let unpushed = 0;
|
||||||
let conflicted = 0;
|
let conflicted = 0;
|
||||||
|
let unreadable = 0;
|
||||||
for (const r of overview.repos) {
|
for (const r of overview.repos) {
|
||||||
|
if (r.status.state === 'error') unreadable += 1;
|
||||||
uncommitted += r.status.counts.uncommitted;
|
uncommitted += r.status.counts.uncommitted;
|
||||||
unpushed += r.status.unpushedCount;
|
unpushed += r.status.unpushedCount;
|
||||||
conflicted += r.status.counts.conflicted;
|
conflicted += r.status.counts.conflicted;
|
||||||
}
|
}
|
||||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
|
||||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
|
||||||
|
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
|
||||||
},
|
},
|
||||||
|
|
||||||
/** One sentence for the tooltip and the screen-reader label. */
|
/** One sentence for the tooltip and the screen-reader label. */
|
||||||
@@ -168,12 +190,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
||||||
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
||||||
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||||
|
if (sum.unreadable)
|
||||||
|
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
|
||||||
if (!bits.length) bits.push('everything is committed and pushed');
|
if (!bits.length) bits.push('everything is committed and pushed');
|
||||||
let where;
|
let where;
|
||||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||||
else {
|
else {
|
||||||
const d = overview.repos[0].status;
|
const d = overview.repos[0].status;
|
||||||
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
where = d.state === 'error' ? overview.repos[0].name : d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||||
}
|
}
|
||||||
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||||
},
|
},
|
||||||
@@ -196,6 +220,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||||
|
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
|
||||||
if (!parts.length) parts.push('✓');
|
if (!parts.length) parts.push('✓');
|
||||||
if (label) label.textContent = parts.join(' ');
|
if (label) label.textContent = parts.join(' ');
|
||||||
const sentence = this._gitStatusSentence(data);
|
const sentence = this._gitStatusSentence(data);
|
||||||
@@ -333,17 +358,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repos = overview.repos;
|
const repos = overview.repos;
|
||||||
if (repos.length === 1) {
|
if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) {
|
||||||
// One repository: the panel is that repository, as it always was.
|
// One repository: the panel is that repository, as it always was. One that git could not read,
|
||||||
|
// or the only one shown of several (the limit is 1), takes the list view below instead, so its
|
||||||
|
// error row or the "Showing the first" notice is not lost.
|
||||||
const d = repos[0].status;
|
const d = repos[0].status;
|
||||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||||
this._renderGitRepoInto(body, d);
|
this._renderGitRepoInto(body, d);
|
||||||
} else {
|
} else {
|
||||||
if (head) head.textContent = `${repos.length} repositories`;
|
if (head) head.textContent = repos.length === 1 ? '1 repository' : `${repos.length} repositories`;
|
||||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||||
if (overview.reposTruncated) {
|
if (overview.reposTruncated) {
|
||||||
body.append(
|
body.append(
|
||||||
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
el(
|
||||||
|
'div',
|
||||||
|
'git-status-more',
|
||||||
|
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +395,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_gitRepoSection(r) {
|
_gitRepoSection(r) {
|
||||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
const d = r.status;
|
const d = r.status;
|
||||||
|
if (d.state === 'error') {
|
||||||
|
// Kept in the list with the reason, rather than silently left out.
|
||||||
|
const row = el('div', 'git-status-repo git-status-repo--error');
|
||||||
|
row.append(el('span', 'git-status-repo-name', r.name));
|
||||||
|
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
|
||||||
|
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
|
||||||
|
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
|
||||||
|
row.append(why);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
const section = el('details', 'git-status-repo');
|
const section = el('details', 'git-status-repo');
|
||||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||||
@@ -582,7 +623,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||||
this._gitDiffView = view;
|
this._gitDiffView = view;
|
||||||
this._renderGitStatusPanel();
|
this._renderGitStatusPanel();
|
||||||
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
|
const qs = new URLSearchParams({
|
||||||
|
repo: repoRoot,
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
...this.gitStatusLimits(),
|
||||||
|
});
|
||||||
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
||||||
// Back, another file or another session while this was in flight: drop the answer.
|
// Back, another file or another session while this was in flight: drop the answer.
|
||||||
if (this._gitDiffView !== view) return;
|
if (this._gitDiffView !== view) return;
|
||||||
|
|||||||
@@ -70,17 +70,13 @@ const HOME_SESSIONS_PILL_LABEL = {
|
|||||||
done: 'done',
|
done: 'done',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
|
/**
|
||||||
|
* Text badge per backend, mirroring the tab strip: only the shell has one. Every
|
||||||
|
* agent CLI (claude included) shows its logo instead, through the same
|
||||||
|
* `run-mode-dot <id>` slot the strip uses (see _buildHomeSessionRow).
|
||||||
|
*/
|
||||||
const HOME_SESSIONS_MODE_BADGE = {
|
const HOME_SESSIONS_MODE_BADGE = {
|
||||||
shell: 'sh',
|
shell: 'sh',
|
||||||
opencode: 'oc',
|
|
||||||
codex: 'cx',
|
|
||||||
gemini: 'gm',
|
|
||||||
antigravity: 'ag',
|
|
||||||
pi: 'pi',
|
|
||||||
grok: 'gk',
|
|
||||||
deepseek: 'ds',
|
|
||||||
omp: 'om',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
@@ -430,6 +426,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
badge.setAttribute('data-i18n-skip', '');
|
badge.setAttribute('data-i18n-skip', '');
|
||||||
badge.textContent = row.modeBadge;
|
badge.textContent = row.modeBadge;
|
||||||
line1.appendChild(badge);
|
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). 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');
|
||||||
|
line1.appendChild(logo);
|
||||||
}
|
}
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
// .session-name is in the i18n skip list: a session name is user content.
|
// .session-name is in the i18n skip list: a session name is user content.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
'.response-viewer-content',
|
'.response-viewer-content',
|
||||||
'.file-preview-content',
|
'.file-preview-content',
|
||||||
'.session-tab-name',
|
'.session-tab-name',
|
||||||
|
'.tab-name',
|
||||||
'.session-name',
|
'.session-name',
|
||||||
'.case-name',
|
'.case-name',
|
||||||
'.notif-item-message',
|
'.notif-item-message',
|
||||||
@@ -45,6 +46,15 @@
|
|||||||
// Exact English-source translations. Technical names, command examples, model
|
// Exact English-source translations. Technical names, command examples, model
|
||||||
// names, keyboard chords, and user-authored content intentionally stay unchanged.
|
// names, keyboard chords, and user-authored content intentionally stay unchanged.
|
||||||
const ZH_CN = Object.freeze({
|
const ZH_CN = Object.freeze({
|
||||||
|
'Default Codex model': 'Codex 默认模型',
|
||||||
|
'Default Codex reasoning effort': 'Codex 默认思考强度',
|
||||||
|
'Use Codex configuration': '使用 Codex 配置',
|
||||||
|
'Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.':
|
||||||
|
'新本地 Codex 会话(包括 WSL)使用的模型 ID。留空时使用 Codex 配置。',
|
||||||
|
'Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.':
|
||||||
|
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
|
||||||
|
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
|
||||||
|
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
|
||||||
'Skip to terminal': '跳转到终端',
|
'Skip to terminal': '跳转到终端',
|
||||||
'Go to main page': '返回主页',
|
'Go to main page': '返回主页',
|
||||||
'Session tabs': '会话标签页',
|
'Session tabs': '会话标签页',
|
||||||
@@ -52,6 +62,8 @@
|
|||||||
'Collapse session sidebar': '收起会话侧边栏',
|
'Collapse session sidebar': '收起会话侧边栏',
|
||||||
'Expand session sidebar': '展开会话侧边栏',
|
'Expand session sidebar': '展开会话侧边栏',
|
||||||
'Filter sessions': '筛选会话',
|
'Filter sessions': '筛选会话',
|
||||||
|
'Search sessions': '搜索会话',
|
||||||
|
'No sessions match': '没有匹配的会话',
|
||||||
'Admin Panel': '管理面板',
|
'Admin Panel': '管理面板',
|
||||||
'Open admin panel': '打开管理面板',
|
'Open admin panel': '打开管理面板',
|
||||||
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
||||||
@@ -96,6 +108,88 @@
|
|||||||
'Split: close the second session': '分屏:关闭第二个会话',
|
'Split: close the second session': '分屏:关闭第二个会话',
|
||||||
'Close split': '关闭分屏',
|
'Close split': '关闭分屏',
|
||||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||||
|
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
|
||||||
|
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
|
||||||
|
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
|
||||||
|
// Counts, exit codes and durations are patterns in translateDynamic.
|
||||||
|
Tiles: '平铺',
|
||||||
|
Split: '分屏',
|
||||||
|
'Tiled sessions': '平铺的会话',
|
||||||
|
'Tiles: show several sessions side by side (right-click for how many)':
|
||||||
|
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
|
||||||
|
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
|
||||||
|
'How many tiles': '窗格数量',
|
||||||
|
// The Tiles button's hover card (the count and the fits note are patterns).
|
||||||
|
'Click: open the grid': '单击:打开平铺网格',
|
||||||
|
'Click: close the grid': '单击:关闭平铺网格',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
|
||||||
|
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
|
||||||
|
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
|
||||||
|
'Toggle Tile Grid': '切换平铺网格',
|
||||||
|
'Focus Tile Left': '聚焦左侧窗格',
|
||||||
|
'Focus Tile Right': '聚焦右侧窗格',
|
||||||
|
'Focus Tile Up': '聚焦上方窗格',
|
||||||
|
'Focus Tile Down': '聚焦下方窗格',
|
||||||
|
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
|
||||||
|
'Move Tile Left': '向左移动窗格',
|
||||||
|
'Move Tile Right': '向右移动窗格',
|
||||||
|
'Move Tile Up': '向上移动窗格',
|
||||||
|
'Move Tile Down': '向下移动窗格',
|
||||||
|
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
|
||||||
|
Drag: '拖动',
|
||||||
|
"a tile's header": '窗格的标题栏',
|
||||||
|
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||||
|
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||||
|
'Remove Focused Tile': '移除聚焦的窗格',
|
||||||
|
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||||
|
'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
|
||||||
|
'a tab': '标签页',
|
||||||
|
'the Tiles button': '平铺按钮',
|
||||||
|
Click: '单击',
|
||||||
|
'Right-click': '右键单击',
|
||||||
|
Arrows: '方向键',
|
||||||
|
'not bound': '未绑定',
|
||||||
|
'Open group as tiles': '以平铺方式打开分组',
|
||||||
|
'No sessions to show as tiles': '没有可平铺显示的会话',
|
||||||
|
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
|
||||||
|
'Zoom this tile': '放大此窗格',
|
||||||
|
'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': '仅支持图像文件',
|
||||||
|
// 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': '此会话的尺寸由它自己的窗口决定',
|
||||||
|
'Terminal not connected: its size is sent when it reconnects': '终端未连接:重新连接后会发送其尺寸',
|
||||||
|
'Could not determine terminal size': '无法确定终端尺寸',
|
||||||
|
'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': '拖动可移动窗格',
|
||||||
|
'Resize tile columns': '调整窗格列宽',
|
||||||
|
'Resize tile rows': '调整窗格行高',
|
||||||
|
Attach: '附加',
|
||||||
|
'Attaching…': '正在附加…',
|
||||||
|
'Not attached': '未附加',
|
||||||
|
'The session ended': '会话已结束',
|
||||||
|
'The agent exited': '智能体已退出',
|
||||||
|
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
|
||||||
|
'Could not attach the session': '无法附加会话',
|
||||||
|
// The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
|
||||||
|
// its exit-code forms and the tab's accessible name are patterns.
|
||||||
|
exited: '已退出',
|
||||||
|
// The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ...
|
||||||
|
// are a pattern; mode codes and product names stay), and the toolbar beside it.
|
||||||
|
'Terminal / Shell': '终端 / Shell',
|
||||||
|
'Send Enter': '发送回车',
|
||||||
|
// The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse
|
||||||
|
// input like Click (单击).
|
||||||
|
Tabs: '标签页',
|
||||||
|
'Toggle Session Sidebar': '切换会话侧边栏',
|
||||||
|
'Copy Selection': '复制选中内容',
|
||||||
|
'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)',
|
||||||
|
'Focus Tabs': '聚焦标签页',
|
||||||
|
Wheel: '滚轮',
|
||||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||||
Notifications: '通知',
|
Notifications: '通知',
|
||||||
@@ -140,10 +234,16 @@
|
|||||||
'Run DeepSeek': '运行 DeepSeek',
|
'Run DeepSeek': '运行 DeepSeek',
|
||||||
'Run OMP': '运行 OMP',
|
'Run OMP': '运行 OMP',
|
||||||
'Run Shell': '运行 Shell',
|
'Run Shell': '运行 Shell',
|
||||||
|
'More tools': '更多工具',
|
||||||
'Select AI backend': '选择 AI 后端',
|
'Select AI backend': '选择 AI 后端',
|
||||||
'Create New Case': '新建案例',
|
'Create New Case': '新建案例',
|
||||||
'Create new case': '新建案例',
|
'Create new case': '新建案例',
|
||||||
'Link Existing': '关联现有目录',
|
'Link Existing': '关联现有目录',
|
||||||
|
// The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS),
|
||||||
|
// which replaced the "+" and gear buttons, and its empty state.
|
||||||
|
'New or link a case…': '新建或关联案例…',
|
||||||
|
'Case settings…': '案例设置…',
|
||||||
|
'No cases match': '没有匹配的案例',
|
||||||
'Add Case': '添加案例',
|
'Add Case': '添加案例',
|
||||||
'Open sessions': '打开会话',
|
'Open sessions': '打开会话',
|
||||||
'Recent Sessions': '最近会话',
|
'Recent Sessions': '最近会话',
|
||||||
@@ -221,6 +321,7 @@
|
|||||||
Running: '运行中',
|
Running: '运行中',
|
||||||
Idle: '空闲',
|
Idle: '空闲',
|
||||||
Working: '工作中',
|
Working: '工作中',
|
||||||
|
Waiting: '等待中',
|
||||||
Today: '今天',
|
Today: '今天',
|
||||||
Home: '主页',
|
Home: '主页',
|
||||||
Local: '本地',
|
Local: '本地',
|
||||||
@@ -242,6 +343,36 @@
|
|||||||
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
|
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
|
||||||
English: 'English',
|
English: 'English',
|
||||||
Appearance: '外观',
|
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: '皮肤',
|
Skin: '皮肤',
|
||||||
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
|
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
|
||||||
'Daylight Blue': '日光蓝',
|
'Daylight Blue': '日光蓝',
|
||||||
@@ -271,6 +402,56 @@
|
|||||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
'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 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||||
|
// 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.
|
||||||
|
'Tab Layout': '标签页布局',
|
||||||
|
'By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.':
|
||||||
|
'按状态:需要你、等待中、工作中和空闲各占一行。按案例:每个案例一个框。台账:对齐的列网格。经典:与以前相同的单一列表。按状态和按案例也会为侧边标签栏和侧边栏分组。Alt+1..9 仍按标签页顺序切换。',
|
||||||
|
'By state (rows per state)': '按状态(每种状态一行)',
|
||||||
|
'By case (clusters)': '按案例(分组框)',
|
||||||
|
'Ledger (aligned columns)': '台账(对齐的列)',
|
||||||
|
'Classic (default)': '经典(默认)',
|
||||||
|
'State Order': '状态顺序',
|
||||||
|
'For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.':
|
||||||
|
'用于按状态的标签页布局。选择在底部会倒转各行,让“需要你”紧挨在终端上方。',
|
||||||
|
'Needs you on top (default)': '“需要你”在顶部(默认)',
|
||||||
|
'Needs you at the bottom': '“需要你”在底部',
|
||||||
|
'Header Stats Style': '顶部栏状态样式',
|
||||||
|
'How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.':
|
||||||
|
'WS、CPU、MEM 和套餐用量窗口的显示方式。紧凑:在两个胶囊中每个数值旁显示一个圆环;磁贴:每个标签位于数值上方,下方带一条进度条。',
|
||||||
|
'As before (bars)': '与以前相同(进度条)',
|
||||||
|
'Compact (default)': '紧凑(默认)',
|
||||||
|
'Tiles (label over value)': '磁贴(标签在数值上方)',
|
||||||
|
// The connection tile's value word in that style (app.js
|
||||||
|
// _connectionTileValueText). Scoped keys on purpose: the bare words also
|
||||||
|
// name other things ("retry" is the orchestrator's Retry button, "LIVE" a
|
||||||
|
// badge in the resume list), and a bare key would translate those too.
|
||||||
|
'Connection tile: live': '已连接',
|
||||||
|
'Connection tile: fallback': '回退',
|
||||||
|
'Connection tile: offline': '离线',
|
||||||
|
'Connection tile: queued': '已排队',
|
||||||
|
'Connection tile: retry': '重连中',
|
||||||
|
// App Settings → Bottom bar, translated as one group (the Git status rows,
|
||||||
|
// #543's two included). Keys are the trimmed label text, without the scope tag.
|
||||||
|
'Bottom bar': '底部栏',
|
||||||
|
'Git status': 'Git 状态',
|
||||||
|
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
|
||||||
|
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
|
||||||
|
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
|
||||||
|
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
|
||||||
|
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
|
||||||
|
'Git status: max repositories': 'Git 状态:最多仓库数',
|
||||||
|
"When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.":
|
||||||
|
'当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。',
|
||||||
|
'Git status: git timeout': 'Git 状态:git 超时',
|
||||||
|
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
|
||||||
|
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
|
||||||
|
'Refresh git status': '刷新 Git 状态',
|
||||||
|
'Close git status': '关闭 Git 状态',
|
||||||
Panels: '面板',
|
Panels: '面板',
|
||||||
Monitor: '监视器',
|
Monitor: '监视器',
|
||||||
'Project Insights': '项目洞察',
|
'Project Insights': '项目洞察',
|
||||||
@@ -351,7 +532,6 @@
|
|||||||
'Remote Access': '远程访问',
|
'Remote Access': '远程访问',
|
||||||
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
||||||
'Tunnel URL': '隧道地址',
|
'Tunnel URL': '隧道地址',
|
||||||
'Upload URL': '上传地址',
|
|
||||||
Updates: '更新',
|
Updates: '更新',
|
||||||
'Current Version': '当前版本',
|
'Current Version': '当前版本',
|
||||||
'Check for Updates': '检查更新',
|
'Check for Updates': '检查更新',
|
||||||
@@ -448,6 +628,11 @@
|
|||||||
'Audio Alerts': '声音提醒',
|
'Audio Alerts': '声音提醒',
|
||||||
'Push Notifications': '推送通知',
|
'Push Notifications': '推送通知',
|
||||||
'Notification Levels': '通知级别',
|
'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: '严重',
|
Critical: '严重',
|
||||||
'Per-Event Settings': '按事件设置',
|
'Per-Event Settings': '按事件设置',
|
||||||
'Permission prompts': '权限提示',
|
'Permission prompts': '权限提示',
|
||||||
@@ -574,6 +759,8 @@
|
|||||||
|
|
||||||
// Dynamic common status / toasts
|
// Dynamic common status / toasts
|
||||||
'Settings saved': '设置已保存',
|
'Settings saved': '设置已保存',
|
||||||
|
'Settings applied': '设置已应用',
|
||||||
|
'Save and keep Settings open': '保存并保持设置打开',
|
||||||
'Settings saved locally': '设置已保存到本机',
|
'Settings saved locally': '设置已保存到本机',
|
||||||
'Tunnel active': '隧道已启用',
|
'Tunnel active': '隧道已启用',
|
||||||
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
||||||
@@ -592,6 +779,13 @@
|
|||||||
'Nothing to copy': '没有可复制的内容',
|
'Nothing to copy': '没有可复制的内容',
|
||||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||||
'Session not found': '未找到会话',
|
'Session not found': '未找到会话',
|
||||||
|
// A native host that would not open a window (app.js openInHostWindow); the
|
||||||
|
// "dashboard" is a web tab.
|
||||||
|
'Could not open a new window for this session': '无法在新窗口中打开此会话',
|
||||||
|
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
|
||||||
|
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
|
||||||
|
// Dictation whose session closed before the text was sent (voice-input.js).
|
||||||
|
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
|
||||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||||
Copy: '复制',
|
Copy: '复制',
|
||||||
@@ -753,6 +947,40 @@
|
|||||||
'Wrap lines': '自动换行',
|
'Wrap lines': '自动换行',
|
||||||
'Unsaved changes': '未保存的更改',
|
'Unsaved changes': '未保存的更改',
|
||||||
Saved: '已保存',
|
Saved: '已保存',
|
||||||
|
'Loading spreadsheet…': '正在加载电子表格…',
|
||||||
|
'This workbook is too large to preview (10 MB limit).': '此工作簿太大,无法预览(上限 10 MB)。',
|
||||||
|
'This workbook has no visible worksheets.': '此工作簿没有可见的工作表。',
|
||||||
|
'This worksheet is empty.': '此工作表为空。',
|
||||||
|
'Some workbook features are not shown': '部分工作簿功能未显示',
|
||||||
|
'Spreadsheet preview timed out.': '电子表格预览超时。',
|
||||||
|
'Spreadsheet preview failed': '电子表格预览失败',
|
||||||
|
'Spreadsheet parser failed.': '电子表格解析器出错。',
|
||||||
|
'Spreadsheet parser failed to start': '电子表格解析器启动失败',
|
||||||
|
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
|
||||||
|
'Spreadsheet parser message failed': '电子表格解析器消息出错',
|
||||||
|
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
|
||||||
|
'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL',
|
||||||
|
// Worker refusals, one sentence per error code (spreadsheet-preview.js
|
||||||
|
// WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The
|
||||||
|
// counted ones are patterns in translateDynamic below.
|
||||||
|
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.':
|
||||||
|
'此工作簿受密码保护或为旧版 .xls 格式,无法预览。',
|
||||||
|
'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。',
|
||||||
|
'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。',
|
||||||
|
'This workbook could not be read. The file may be damaged or not a valid .xlsx file.':
|
||||||
|
'无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。',
|
||||||
|
// The features the preview leaves out (spreadsheet-preview.js warningText).
|
||||||
|
// Scoped keys on purpose: a bare 'charts' or 'macros' key would also
|
||||||
|
// translate a folder or case of that name (a Helm chart's charts/, a dbt
|
||||||
|
// project's macros/) in the Files panel and the case picker.
|
||||||
|
'Spreadsheet feature: charts': '图表',
|
||||||
|
'Spreadsheet feature: drawings': '绘图',
|
||||||
|
'Spreadsheet feature: pivot tables': '数据透视表',
|
||||||
|
'Spreadsheet feature: external links': '外部链接',
|
||||||
|
'Spreadsheet feature: macros': '宏',
|
||||||
|
'Formula has no cached result': '公式没有缓存的计算结果',
|
||||||
|
'Unsupported cell value': '不支持的单元格值',
|
||||||
|
'Unsupported number format': '不支持的数字格式',
|
||||||
'Export as JSON': '导出为 JSON',
|
'Export as JSON': '导出为 JSON',
|
||||||
'Export as Markdown': '导出为 Markdown',
|
'Export as Markdown': '导出为 Markdown',
|
||||||
'Mark all read': '全部标为已读',
|
'Mark all read': '全部标为已读',
|
||||||
@@ -951,6 +1179,17 @@
|
|||||||
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
|
||||||
|
const TILE_STATE_ZH = {
|
||||||
|
'needs you': '需要你',
|
||||||
|
error: '错误',
|
||||||
|
waiting: '等待中',
|
||||||
|
working: '工作中',
|
||||||
|
idle: '空闲',
|
||||||
|
done: '已完成',
|
||||||
|
exited: '已退出',
|
||||||
|
};
|
||||||
|
|
||||||
function translateDynamic(source) {
|
function translateDynamic(source) {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
||||||
@@ -969,12 +1208,82 @@
|
|||||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||||
|
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
|
||||||
|
[/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`],
|
||||||
|
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
|
||||||
|
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||||
|
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||||
// Group names are user text: they pass through untranslated.
|
// Group names are user text: they pass through untranslated.
|
||||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||||
[
|
[
|
||||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||||
],
|
],
|
||||||
|
// Tile grid: counts, exit codes and durations pass through.
|
||||||
|
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
|
||||||
|
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
|
||||||
|
[
|
||||||
|
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
|
||||||
|
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
|
||||||
|
],
|
||||||
|
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
|
||||||
|
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
|
||||||
|
[
|
||||||
|
/^The grid already holds what this window fits \((\d+)\)$/,
|
||||||
|
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
|
||||||
|
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
|
||||||
|
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The window is too small for (\d+) tiles: showing the focused one$/,
|
||||||
|
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||||
|
],
|
||||||
|
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||||
|
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
|
||||||
|
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
|
||||||
|
// and where the model came from when the CLI did not report it. The harness
|
||||||
|
// and model names pass through untranslated.
|
||||||
|
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
|
||||||
|
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
|
||||||
|
[/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`],
|
||||||
|
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
|
||||||
|
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
|
||||||
|
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
|
||||||
|
// The tab's exited-agent badge, and the tab's accessible name carrying it.
|
||||||
|
// The session name is user text: it passes through untranslated.
|
||||||
|
[/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
|
||||||
|
[/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
|
||||||
|
[
|
||||||
|
/^(.+) session, agent exited \(signal (\d+)\)$/,
|
||||||
|
(_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
|
||||||
|
],
|
||||||
|
[/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
|
||||||
|
[/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
|
||||||
|
// A session name is user text: it passes through untranslated.
|
||||||
|
[
|
||||||
|
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
|
||||||
|
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
|
||||||
|
],
|
||||||
|
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
|
||||||
|
// is required: bare state words stay out of the table, they collide with
|
||||||
|
// state strings on other surfaces (see mobile-overview.js).
|
||||||
|
[
|
||||||
|
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
|
||||||
|
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
|
||||||
|
],
|
||||||
|
// 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']}`,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [pattern, replacement] of patterns) {
|
for (const [pattern, replacement] of patterns) {
|
||||||
const match = source.match(pattern);
|
const match = source.match(pattern);
|
||||||
@@ -1033,6 +1342,23 @@
|
|||||||
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
|
||||||
|
// a pane changes: thousands of mutation records a second with a grid of tiles,
|
||||||
|
// each paying a closest() over the whole skip list. All rows of one terminal
|
||||||
|
// share that parent, so its own shouldSkip() verdict is kept once it says
|
||||||
|
// skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside
|
||||||
|
// its `.xterm`. A rows container that is not skipped is never kept: its rows
|
||||||
|
// go through the full check below like any other node.
|
||||||
|
const skippedRows = new WeakSet();
|
||||||
|
function isSkippedRow(node) {
|
||||||
|
const rows = node.parentNode;
|
||||||
|
if (!rows?.classList?.contains('xterm-rows')) return false;
|
||||||
|
if (skippedRows.has(rows)) return true;
|
||||||
|
if (!shouldSkip(rows)) return false;
|
||||||
|
skippedRows.add(rows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSkipText(node) {
|
function shouldSkipText(node) {
|
||||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||||
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
||||||
@@ -1128,6 +1454,13 @@
|
|||||||
observer = new MutationObserver((mutations) => {
|
observer = new MutationObserver((mutations) => {
|
||||||
if (applying) return;
|
if (applying) return;
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
|
// A change inside a skipped surface cannot need translating: every
|
||||||
|
// node it adds or edits sits under the same skip ancestor, so both
|
||||||
|
// translators would return on their own closest() check anyway. One
|
||||||
|
// check per record instead of one per text node and attribute matters
|
||||||
|
// for xterm's DOM renderer, which replaces rows every frame (the split
|
||||||
|
// pane, every tile of the grid).
|
||||||
|
if (isSkippedRow(mutation.target) || shouldSkip(mutation.target)) continue;
|
||||||
if (mutation.type === 'characterData') translateNode(mutation.target);
|
if (mutation.type === 'characterData') translateNode(mutation.target);
|
||||||
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
||||||
for (const added of mutation.addedNodes) translateNode(added);
|
for (const added of mutation.addedNodes) translateNode(added);
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const total = files.length;
|
const total = files.length;
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let failed = 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 results = new Array(total); // preserve selection order for insertion
|
||||||
const progress = () =>
|
const progress = () =>
|
||||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||||
@@ -173,6 +174,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
failed++;
|
||||||
|
if (!failReason && err && err.message) failReason = err.message;
|
||||||
console.warn('Image upload failed:', err);
|
console.warn('Image upload failed:', err);
|
||||||
results[i] = null;
|
results[i] = null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -196,7 +198,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||||
if (failed > 0) parts.push(`${failed} failed`);
|
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
||||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||||
|
|||||||