Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b92f2991 | ||
|
|
aa3730aace | ||
|
|
6234aae919 | ||
|
|
34439d18e5 | ||
|
|
3d59e59e37 | ||
|
|
019118ba9d | ||
|
|
45a205b4e5 | ||
|
|
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 | ||
|
|
bcdccd14c4 | ||
|
|
3ae22f64a4 | ||
|
|
90649fc363 | ||
|
|
f21ab39a89 | ||
|
|
9e032bdc3e | ||
|
|
497711a05e | ||
|
|
fe9b209f67 | ||
|
|
fbd69e62aa | ||
|
|
a54ad81684 | ||
|
|
0ec17633ba | ||
|
|
0c71b753ef | ||
|
|
5e3dbf2057 | ||
|
|
d1bbb4cc26 | ||
|
|
e2f56dc077 | ||
|
|
ead3d34411 | ||
|
|
310f20b288 | ||
|
|
f1537a7887 | ||
|
|
55cafc282f | ||
|
|
bb5fd5ee97 | ||
|
|
bebf0db792 | ||
|
|
e3dfbf6591 | ||
|
|
9b0d305223 | ||
|
|
aad9c248dc | ||
|
|
4c2fdd5f5a | ||
|
|
ab7e89873f | ||
|
|
c36be7bb94 | ||
|
|
ac94f339ac | ||
|
|
88f5a43a9f | ||
|
|
aca23aa404 | ||
|
|
f16f294576 | ||
|
|
737a2527d6 | ||
|
|
6f88e40b77 | ||
|
|
2c38e77f8a | ||
|
|
cf26853390 | ||
|
|
192a5994e0 | ||
|
|
566365e127 | ||
|
|
ff94637718 | ||
|
|
2063d15c20 | ||
|
|
fed3a0897a | ||
|
|
d9c760609f | ||
|
|
74e8015783 | ||
|
|
fea5626efc | ||
|
|
bd109d3b16 | ||
|
|
9fa44109b8 | ||
|
|
51b6be3e7a | ||
|
|
3e768e1b5b | ||
|
|
92f51fa619 | ||
|
|
06aba94ef1 | ||
|
|
ea80c5f471 | ||
|
|
0c4bb5169f | ||
|
|
b2423c90ce | ||
|
|
4152ee1015 | ||
|
|
90dfa328a8 | ||
|
|
294ce0a667 | ||
|
|
58b52fceff | ||
|
|
4fc75d494f | ||
|
|
948c7c54dd | ||
|
|
cd9218c23e | ||
|
|
db9a39405b | ||
|
|
d1bfbb4fcf | ||
|
|
bd4a1e9886 | ||
|
|
0ae5ce017a | ||
|
|
97cb5b5799 | ||
|
|
00b935abe6 | ||
|
|
ab96e74e69 | ||
|
|
bfc164a262 | ||
|
|
1b89d7a387 | ||
|
|
d9174a7a03 | ||
|
|
4150707a6b | ||
|
|
6944f842c7 | ||
|
|
ffaa5ee80c | ||
|
|
6aecc3b858 | ||
|
|
470cf79776 | ||
|
|
06c4c7da16 | ||
|
|
7917273188 | ||
|
|
b451b3851e | ||
|
|
6d6e7da481 | ||
|
|
52267f8617 | ||
|
|
d00229ee29 | ||
|
|
36af183f97 | ||
|
|
c029cea620 | ||
|
|
b8038a592c | ||
|
|
ccd6df893f | ||
|
|
e082d8e438 | ||
|
|
fa53a5751e | ||
|
|
23d145b121 | ||
|
|
5724e0c8b4 | ||
|
|
9649b5019b | ||
|
|
ec2a036543 | ||
|
|
c197e9370b | ||
|
|
d887002ca8 | ||
|
|
574f3db58b | ||
|
|
17a976fa2e | ||
|
|
7064b3c1d5 | ||
|
|
01f403dc1b | ||
|
|
7d3e27fb6d | ||
|
|
c1811fd716 | ||
|
|
2d0ffb71aa | ||
|
|
2cf37529e9 | ||
|
|
df398c5c68 | ||
|
|
d68a173a23 | ||
|
|
0ff57ce304 | ||
|
|
f39c66e4e8 | ||
|
|
4398dbfad0 | ||
|
|
c9a5fdab00 | ||
|
|
7616de13de | ||
|
|
af032fc81a | ||
|
|
41a10b159e | ||
|
|
e6b258fc44 | ||
|
|
98c6c1881d | ||
|
|
d65ee4f89d | ||
|
|
e85b4f34dd | ||
|
|
7cbce5bf6c | ||
|
|
3df113fc54 | ||
|
|
0ae39cdd94 | ||
|
|
45db24bacf | ||
|
|
4123d229f4 | ||
|
|
bfab172608 | ||
|
|
ee1a155e2c | ||
|
|
4edb7b8f80 | ||
|
|
0b122e2c76 | ||
|
|
2d96472dbe | ||
|
|
e0542bb172 | ||
|
|
0720526b64 | ||
|
|
cbb1435a46 | ||
|
|
8e4606c57b |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Spreadsheet preview: workbooks written by openpyxl (the Python library most agents use to make .xlsx files) now open when they contain a cell comment, a table or a chart. Before, any one of those made the whole preview fail with "Spreadsheet preview failed".
|
||||||
@@ -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.33.3",
|
"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,152 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6f88e40: ### Thanks
|
||||||
|
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
|
||||||
|
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
|
||||||
|
|
||||||
|
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
|
||||||
|
|
||||||
|
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
|
||||||
|
|
||||||
|
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
|
||||||
|
|
||||||
|
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
|
||||||
|
|
||||||
|
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
|
||||||
|
|
||||||
|
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
|
||||||
|
|
||||||
|
## 1.34.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6aecc3b: ### Thanks
|
||||||
|
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||||
|
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||||
|
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||||
|
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||||
|
|
||||||
|
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||||
|
|
||||||
|
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||||
|
|
||||||
|
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||||
|
|
||||||
|
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||||
|
|
||||||
|
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||||
|
|
||||||
|
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||||
|
|
||||||
|
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||||
|
|
||||||
|
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||||
|
|
||||||
## 1.33.3
|
## 1.33.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.33.3 (must match `package.json`)
|
**Version**: 1.41.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -123,11 +123,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
||||||
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
||||||
|
|
||||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and the Playwright tests in `BROWSER_TEST_GLOBS`; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`). `browser-suite.yml` runs `npm run test:browser` (minus codex-predictive-echo) nightly and on demand via `workflow_dispatch`; it is informational and never gates a push or PR.
|
||||||
|
|
||||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||||
|
|
||||||
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, upload.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
@@ -137,7 +137,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||||
|
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
|
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
|
||||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||||
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
||||||
@@ -145,6 +146,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||||
|
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
|
||||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||||
|
|
||||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||||
@@ -198,6 +200,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||||
|
|
||||||
|
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||||
|
|
||||||
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
||||||
|
|
||||||
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||||
@@ -234,7 +238,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||||
|
|
||||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||||
|
|
||||||
@@ -244,9 +248,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||||
|
|
||||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
|
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||||
|
|
||||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||||
|
|
||||||
@@ -254,7 +260,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||||
|
|
||||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||||
|
|
||||||
@@ -270,9 +276,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||||
|
|
||||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||||
|
|
||||||
|
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. SuchLine truncated
|
||||||
|
|
||||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||||
|
|
||||||
@@ -280,7 +288,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||||
|
|
||||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||||
|
|
||||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||||
@@ -289,7 +297,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||||
|
|
||||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
**Prompt uploads** (`POST /api/sessions/:id/paste-image`; client wiring in `image-input.js` and `keyboard-accessory.js`): a pasted file is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
|
||||||
|
|
||||||
|
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--responseLine truncated
|
||||||
|
|
||||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||||
|
|
||||||
@@ -299,6 +309,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||||
|
|
||||||
|
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||||
|
|
||||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||||
|
|
||||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||||
@@ -319,13 +331,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||||
|
|
||||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||||
|
|
||||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Test: `test/mobile-tab-tap-zones.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||||
|
|
||||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||||
|
|
||||||
|
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||||
|
|
||||||
|
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
|
||||||
|
|
||||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
||||||
|
|
||||||
@@ -341,7 +357,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||||
|
|
||||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||||
|
|
||||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||||
|
|
||||||
@@ -367,7 +383,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||||
|
|
||||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||||
|
|
||||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||||
|
|
||||||
@@ -379,11 +395,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||||
|
|
||||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
|
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -424,13 +440,13 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
|||||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
||||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
||||||
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
|
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
|
||||||
|
|
||||||
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
||||||
|
|
||||||
## State Files
|
## State Files
|
||||||
|
|
||||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||||
|
|
||||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||||
|
|
||||||
@@ -453,7 +469,7 @@ npm run test:perf # wall-clock benchmarks — need an otherwise idle machin
|
|||||||
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
|
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above).
|
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above). The browser suite (not mobile or perf) also runs nightly in `.github/workflows/browser-suite.yml`, which catches a regression after it lands, not before.
|
||||||
|
|
||||||
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
||||||
|
|
||||||
@@ -463,7 +479,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
|
|||||||
|
|
||||||
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
||||||
|
|
||||||
**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
|
**Ports**: every in-process test server binds port 0 (the one exception, `test/codex-predictive-echo.test.ts`, starts a separate lab server process on 3222). A test that constructs `WebServer` reads `server.boundPort` (mobile tests: `createTestServer()`, then `server.boundPort`); a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a number or `…PORT` constant. A test that needs a port nothing listens on binds 0, reads it and closes (`closedPort()` in `test/daemon-control.test.ts`), never "the server's port + 1". Never 3000 (the live instance).
|
||||||
|
|
||||||
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200.
|
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200.
|
||||||
|
|
||||||
@@ -481,7 +497,7 @@ curl -sk https://localhost:3000/api/subagents | jq # Background agents
|
|||||||
cat ~/.codeman/state.json | jq # Persisted state
|
cat ~/.codeman/state.json | jq # Persisted state
|
||||||
```
|
```
|
||||||
|
|
||||||
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
|
Legacy screenshots (deprecated): `GET/POST /api/screenshots` still read and write `~/.codeman/screenshots/`, but the upload page that fed them is gone, they log a one-time deprecation warning, and they are removed in a later MAJOR, after at least one MINOR release that carries the warning (`docs/versioning-policy.md`). To hand a file to an agent use `POST /api/sessions/:id/paste-image` (see Prompt uploads).
|
||||||
|
|
||||||
## Performance & Limits
|
## Performance & Limits
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -444,8 +448,11 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
|||||||
## More Features
|
## More Features
|
||||||
|
|
||||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||||
|
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
|
||||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||||
|
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
||||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||||
|
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
@@ -728,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-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
#### Step 1: install it
|
#### Step 1: install it
|
||||||
|
|
||||||
| How | Command | Scope |
|
| How | Command | Scope |
|
||||||
@@ -933,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 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
export const BROWSER_TEST_GLOBS = [
|
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-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',
|
||||||
@@ -31,8 +34,19 @@ 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/key-tester.browser.test.ts',
|
||||||
|
'test/webhook-settings.browser.test.ts',
|
||||||
|
'test/case-custom-path.browser.test.ts',
|
||||||
|
'test/doctor-settings.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/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
|
||||||
@@ -80,6 +87,93 @@ the HTTP status.
|
|||||||
|
|
||||||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||||||
|
|
||||||
|
## Cron jobs
|
||||||
|
|
||||||
|
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
|
||||||
|
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
|
||||||
|
is the unversioned alias. These routes use the response envelope above; the table
|
||||||
|
lists the value inside `data` on success.
|
||||||
|
|
||||||
|
| Method | Path | Request body | Response `data` |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
|
||||||
|
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
|
||||||
|
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
|
||||||
|
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
|
||||||
|
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
|
||||||
|
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
|
||||||
|
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
|
||||||
|
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
|
||||||
|
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
|
||||||
|
|
||||||
|
### Job request fields
|
||||||
|
|
||||||
|
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
|
||||||
|
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
|
||||||
|
are required according to the selected prompt and schedule:
|
||||||
|
|
||||||
|
| Field | Type / validation |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | String, 1–200 characters |
|
||||||
|
| `agentType` | A supported session mode (including `shell`) |
|
||||||
|
| `workingDir` | Existing, allowed working-directory path |
|
||||||
|
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
|
||||||
|
| `promptMode` | `inline_text` or `prompt_file_path` |
|
||||||
|
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
|
||||||
|
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
|
||||||
|
| `inputMode` | `paste` or `typed` |
|
||||||
|
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
|
||||||
|
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
|
||||||
|
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
|
||||||
|
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
|
||||||
|
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
|
||||||
|
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
|
||||||
|
| `enabled` | Boolean |
|
||||||
|
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
|
||||||
|
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
|
||||||
|
| `notes` | Optional string, at most 2000 characters |
|
||||||
|
|
||||||
|
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
|
||||||
|
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
|
||||||
|
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
|
||||||
|
policy, and does not change the schedule. `activeAgents` counts live sessions of
|
||||||
|
the same agent type, excluding sessions created by this job.
|
||||||
|
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
|
||||||
|
also closes the previous run's session before launching, even if it is still working.
|
||||||
|
|
||||||
|
### Job and run response fields
|
||||||
|
|
||||||
|
`CronJob` contains the request fields plus server-maintained `id`, optional
|
||||||
|
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
|
||||||
|
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
|
||||||
|
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
|
||||||
|
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
|
||||||
|
|
||||||
|
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
|
||||||
|
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
|
||||||
|
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
|
||||||
|
Run times are also Unix milliseconds. Status is one of `created`,
|
||||||
|
`session_started`, `prompt_sent`, `failed`, or `skipped`.
|
||||||
|
|
||||||
|
Prompt delivery continues asynchronously after session launch, so `Run Now` can
|
||||||
|
return `session_started` before the prompt is sent. Read run history for subsequent
|
||||||
|
updates, but do not assume a terminal status will follow: if the session is closed
|
||||||
|
during the readiness wait or the server restarts before delivery, the run can remain
|
||||||
|
`session_started` indefinitely with `finishedAt: null`.
|
||||||
|
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
|
||||||
|
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
|
||||||
|
|
||||||
|
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
|
||||||
|
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
|
||||||
|
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
|
||||||
|
launch-command job without the required privilege grant. Invalid definitions or
|
||||||
|
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
|
||||||
|
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
|
||||||
|
|
||||||
|
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
|
||||||
|
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
|
||||||
|
`cron:runUpdated` SSE events.
|
||||||
|
|
||||||
## Long-polling (agent wait)
|
## Long-polling (agent wait)
|
||||||
|
|
||||||
Three calls block until something happens instead of answering immediately. They
|
Three calls block until something happens instead of answering immediately. They
|
||||||
@@ -445,6 +539,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 +599,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,
|
||||||
@@ -700,6 +855,46 @@ normal `caseName`/`mode`/etc. body)
|
|||||||
jarring than a full relaunch, and folding it into the one-shot path is
|
jarring than a full relaunch, and folding it into the one-shot path is
|
||||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||||
|
|
||||||
|
## Creating a case in a custom folder
|
||||||
|
|
||||||
|
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
|
||||||
|
|
||||||
|
The target is judged before anything is written:
|
||||||
|
|
||||||
|
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
|
||||||
|
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
|
||||||
|
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
|
||||||
|
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
|
||||||
|
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
|
||||||
|
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
|
||||||
|
|
||||||
|
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
|
||||||
|
|
||||||
|
## Git status
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
- **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 worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||||
|
|
||||||
|
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:
|
||||||
|
`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`.
|
||||||
|
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
|
||||||
|
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
|
||||||
|
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
|
||||||
|
|
||||||
|
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||||
|
|
||||||
## CLI management
|
## CLI management
|
||||||
|
|
||||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||||
@@ -713,6 +908,45 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
|
|||||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||||
|
|
||||||
|
## MCP server sync
|
||||||
|
|
||||||
|
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
||||||
|
|
||||||
|
| Method | Path | Body | Notes |
|
||||||
|
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
|
||||||
|
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
|
||||||
|
|
||||||
|
Result (`data`):
|
||||||
|
|
||||||
|
- `applied` — `false` for the dry run.
|
||||||
|
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
||||||
|
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
|
||||||
|
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
|
||||||
|
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
|
||||||
|
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
|
||||||
|
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
|
||||||
|
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
|
||||||
|
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
|
||||||
|
|
||||||
|
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
|
||||||
|
|
||||||
|
## Webhook notifications
|
||||||
|
|
||||||
|
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
|
||||||
|
|
||||||
|
| Method | Path | Body | Notes |
|
||||||
|
| ------ | -------------------- | -------------------------------------------- | ----- |
|
||||||
|
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
|
||||||
|
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
|
||||||
|
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
|
||||||
|
|
||||||
|
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||||
|
|
||||||
## Voice dictation
|
## Voice dictation
|
||||||
|
|
||||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
|
|||||||
|
|
||||||
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessionsLine truncated
|
||||||
|
|
||||||
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
|
|||||||
|
|
||||||
⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
|
⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
|
||||||
|
|
||||||
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
|
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
|
||||||
|
|
||||||
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
|
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
|
||||||
|
|
||||||
@@ -175,15 +175,19 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio
|
|||||||
|
|
||||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||||
|
|
||||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
|
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache.
|
||||||
|
|
||||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
**Every family is drawn, and the SELECTED tab's is emphasized** (2026-10-07): the family the active tab spawned, plus the family it was spawned into (its parent and siblings), get `<g class="lineage-family lineage-family--focus">`: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn LAST so no other family covers it. A tab that is both a child and a parent emphasizes both; a web tab or a tab without lineage emphasizes nothing. ⚠️ Drawing ONLY the selected family was built first and rejected by the owner the same day ("I wanna see all the connections always but when I select one … make thicker"), so do not reintroduce a filter. ⚠️ Lanes follow STRIP order (`index % CodemanLineage.MAX_LANES`, 3, since a 12px row gap fits three lanes at 3.5px), never the selection: only the draw order changes, so a family's lines never jump when you click a tab; families sharing a lane are told apart by colour. ⚠️ Because the emphasis depends on the selection, `_updateActiveTabImmediate()` redraws whenever any lineage exists (`_lineageTotalEdges > 0`), and the incremental tab render redraws on the same condition (a child's `parentSessionId` or working state can arrive there). `_lineageEdgeCount` counts what is DRAWN and still gates the strip-scroll redraw. ⚠️ **A selection keeps resizing tabs AFTER that redraw**: the active tab reveals its gear and close icons by transitioning their padding (~150ms), which can move tabs or re-wrap a row, so the strip's `transitionend` (size properties only: `width`, `max-width`, `padding*`) triggers one more coalesced redraw. Without it the trunk hung under the tab's old position (measured ~10px off, more after a re-wrap).
|
||||||
|
|
||||||
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
**Geometry is a tree routed through the gaps** (`computeLineageTree()` + `computeLineageRows()`, constants.js, pure and unit-tested): one rounded orthogonal tree per spawning tab. Every route starts at the PARENT's bottom-center and ends at one child's bottom-center, so a parent's routes share their first stretch exactly and overlay into one trunk with a branch per child; that is also what keeps a working child's dashes in phase with its siblings along the shared part. A route moves horizontally only inside a row gap (the gap under each endpoint's row, at the row's TALLEST tab, since the active tab is 2px taller) and vertically only along a tab's own stem or the SPINE, a channel left of every row that joins the gaps of different rows. In the vertical rail the same idea is one shared track down `--lineage-vertical-gutter`. `test/session-lineage-lines.test.ts` pins "never crosses a tab" as a property over a five-row fixture. ⚠️ **The room is reserved, not borrowed**: `.session-tabs.lineage-tree` (styles.css) adds bottom padding for the last row's gap, and on a wrapped strip a 12px row gap plus a 20px left channel for the spine. `_syncLineageGutter()` toggles it from the top of `updateTabOverflowMode()`, BEFORE the wrap is measured, and keys it on whether ANY family exists, never on the selection: the header's height is the terminal's height, so a class that followed the selection would resize the PTY on every tab switch. ⚠️ **Opacity lives on the family `<g class="lineage-family">`, not on the paths**: translucent paths overlapping along the trunk would stack into a brighter line than the branches. Working routes are appended first so an idle sibling's solid stroke covers the shared trunk. Style is deliberately quiet (1.5px, rounded, a 1px dark outline instead of the old double glow): the lines no longer cross text, so they no longer need a glow to survive it, and a dash now means "this child is working".
|
||||||
|
|
||||||
Further detail (current geometry and colors, superseding the dip numbers above where they differ): ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels.
|
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so the lines paint OVER it, which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer. The sidebar layout draws no lineage.
|
||||||
|
|
||||||
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute (nested inside the family group is fine), so tagging them this way is the whole reason the lines get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` scrolls, so a tab scrolled out of the strip still HAS a rect, one lying over the logo or the header buttons; an endpoint outside the strip is SKIPPED (a child is left out, a parent drops its whole family), since clamping would point at a tab that is not there, and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry, focus rule, routing room, colours), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
||||||
|
|
||||||
|
History: before the tree, every pair got a U-bridge bezier hanging below the strip, and its dip was mis-tuned in both directions (a 44px cap flattened strip-wide spans into a straight thread, #285; a 104px cap plus the row offset bowed ~106px into the terminal, 2026-08-15). The tree removed the dip entirely.
|
||||||
|
|
||||||
|
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every line leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2"; per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the lines below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker). ⚠️ The draw pass claims a color for EVERY family in strip order, drawn or not, so which family happens to be selected first never decides who gets the blue. Colors are set inline as `--lineage-color` so styles.css keeps owning weight and dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
||||||
|
|
||||||
### Auto-named sessions (first prompt → tab title)
|
### Auto-named sessions (first prompt → tab title)
|
||||||
|
|
||||||
@@ -191,7 +195,7 @@ Further detail (current geometry and colors, superseding the dip numbers above w
|
|||||||
|
|
||||||
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
|
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
|
||||||
|
|
||||||
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` line feed never passes through the session.
|
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
|
||||||
|
|
||||||
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
|
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
|
||||||
|
|
||||||
@@ -213,15 +217,15 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
|||||||
|
|
||||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||||
|
|
||||||
**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||||
|
|
||||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||||
|
|
||||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||||
|
|
||||||
@@ -231,7 +235,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
|||||||
|
|
||||||
**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
|
**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
|
||||||
|
|
||||||
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`.
|
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. ⚠️ **The close is optimistic**: `closeSession()` runs `_onSessionDeleted({ id })` itself (the split, tile, detached-window, WebSocket and per-session teardown, idempotent so the real broadcast finds nothing left), does the follow-up selection and renders the strip with `renderSessionTabs({ immediate: true })` BEFORE sending the DELETE; the server's kill takes a few hundred ms and a tab that sat there that long read as a dead button. `_onSessionCreated`/`_onSessionUpdated` ignore ids in `_closingSessions`, so no upsert brings the row back mid-request. `_apiDelete` resolves a Response (or `null`), never throws: OK or 404 is closed; anything else is checked with `GET /api/sessions/:id` (a delete can land and lose its reply, after its broadcast was already spent) and only a session that still exists gets its row back at its old index, with the error toast. Tests: `test/session-close-fallback.test.ts`.
|
||||||
|
|
||||||
### Circuit breakers: Ralph and PTY-exit
|
### Circuit breakers: Ralph and PTY-exit
|
||||||
|
|
||||||
@@ -241,6 +245,8 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne
|
|||||||
|
|
||||||
**Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-<fragment>` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manLine truncated
|
**Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-<fragment>` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manLine truncated
|
||||||
|
|
||||||
|
⚠️ **The tab's badge is translated (zh-CN), so its updates compare with English, never the DOM.** `applyPaneExitBadge()` remembers the last English badge text (`data-label`) and the tab's accessible name (`data-aria-source`), both also seeded by the full render, and writes only when those change: the translator rewrites both in zh-CN, and a DOM compare (or an unconditional set) would write the English back on every incremental pass for it to translate again. i18n.js has `exited` and patterns for `exited (N)`, `exited (signal N)` and `<name> session, agent exited ...` (the name passed through); `test/i18n-exit-run-help.test.ts` covers every `paneExitLabel()` form.
|
||||||
|
|
||||||
**A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close:
|
**A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close:
|
||||||
|
|
||||||
- ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep.
|
- ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep.
|
||||||
@@ -248,7 +254,7 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne
|
|||||||
- **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay.
|
- **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay.
|
||||||
- **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand.
|
- **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand.
|
||||||
|
|
||||||
Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.claude-images` while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
|
Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.codeman-uploads` (and the pre-move `.claude-images`) while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
|
||||||
|
|
||||||
### Dead-pane respawn: the resume pin
|
### Dead-pane respawn: the resume pin
|
||||||
|
|
||||||
@@ -260,7 +266,7 @@ Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit
|
|||||||
|
|
||||||
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
|
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
|
||||||
|
|
||||||
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
|
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
|
||||||
|
|
||||||
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
||||||
|
|
||||||
@@ -296,7 +302,18 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
|||||||
|
|
||||||
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs.
|
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs.
|
||||||
|
|
||||||
⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job.
|
⚠️ **The frontend READS and EDITS the layout.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections and edits it. The rules that keep it safe:
|
||||||
|
|
||||||
|
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing.
|
||||||
|
- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`).
|
||||||
|
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. The one exception is the rail's **Search sessions** box (#580): while it holds text `_projectTabGroups()` passes `collapsedGroupIds: []`, so every group is DRAWN open and a match inside a collapsed one can show, and `toggleTabGroupCollapsed()` refuses; the stored set and its localStorage key are never written, so clearing the search puts the collapse back exactly as it was (see Session list layout, the rail search).
|
||||||
|
- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible.
|
||||||
|
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
|
||||||
|
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||||
|
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
|
||||||
|
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||||
|
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||||
|
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
|
||||||
|
|
||||||
⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent.
|
⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent.
|
||||||
|
|
||||||
@@ -310,7 +327,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
|||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted` (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted` (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||||
|
|
||||||
⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints.
|
⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir, and skips one that does not answer, such as a linked case on an unreachable mount, without waiting on it: `applyWorkspaceHooks` asks the bounded path probe, retries one capacity-refused probe past the bulk cap, and skips whatever is still "unknown" rather than touching it with an unbounded `lstat`). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints.
|
||||||
|
|
||||||
The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path.
|
The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path.
|
||||||
|
|
||||||
@@ -334,7 +351,7 @@ The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings →
|
|||||||
|
|
||||||
⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up.
|
⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up.
|
||||||
|
|
||||||
⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's `.claude-images`.
|
⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's upload dirs (`.codeman-uploads`, and the pre-move `.claude-images`).
|
||||||
|
|
||||||
⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`.
|
⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`.
|
||||||
|
|
||||||
@@ -362,6 +379,10 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
|||||||
|
|
||||||
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
||||||
|
|
||||||
|
**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk,Line truncated
|
||||||
|
|
||||||
|
⚠️ **ExcelJS follows a worksheet's comment, comment-note (VML), table and drawing links UNCHECKED, so the worker repairs its parsed model before it links it.** ExcelJS 4.4.0's `reconcile` looks each relationship `Target` up verbatim in maps keyed by its own spelling (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, `drawing1`) and dereferences the result. openpyxl, which most agents write spreadsheets with, uses absolute Targets, its own part names (`/xl/comments/comment1.xml`, which ExcelJS never even loads) and a drawing XML ExcelJS parses to nothing, so a workbook with ONE comment, table or chart threw (`reading 'comments'`, `'name'`, `'anchors'`) and the whole preview failed. Right after `new ExcelJS.Workbook()` the worker wraps `workbook.xlsx.reconcile` (which `load()` calls once on the parsed model) to run `repairExcelJsModel()` (core) first: it re-points a link to the key ExcelJS stored for the same part (openpyxl's `/xl/tables/table1.xml` becomes `../tables/table1.xml`, so the table is kept), and drops a link, `<tablePart>`, `<drawing>` or `<hyperlink>` reference, or parsed-to-nothing drawing/table that would dereference nothing. It never adds anything, looks keys up as own properties only (a `__proto__` Target resolves to nothing), and the preview shows none of these parts. The wrap throws if a future ExcelJS renames `reconcile`. Real openpyxl 3.1.5 fixtures (`test/fixtures/xlsx/openpyxl-{comment,table,chart}.xlsx`) fail the worker tests without it.
|
||||||
|
|
||||||
### Filesystem path picker
|
### Filesystem path picker
|
||||||
|
|
||||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||||
@@ -655,13 +676,15 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
|||||||
|
|
||||||
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
|
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
|
||||||
|
|
||||||
|
**A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events.
|
||||||
|
|
||||||
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
|
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
|
||||||
|
|
||||||
1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top).
|
1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top).
|
||||||
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
|
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
|
||||||
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
|
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
|
||||||
|
|
||||||
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. `opencode` used to be in that list and no longer is: its mouse DECSETs are stripped server-side (`isMuxMouseStripMode`, see the strip-flavours invariant), so a plain drag selects there. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
||||||
|
|
||||||
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest:
|
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest:
|
||||||
|
|
||||||
@@ -707,7 +730,7 @@ The guard is a one-shot flag on each trap rather than a browser test or a readin
|
|||||||
|
|
||||||
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
|
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
|
||||||
|
|
||||||
Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones.
|
Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones. Apply (`applyAppSettings()`) sits beside Save in the same actions and saves without closing; it only sets a one-shot flag that `saveAppSettings()` consumes before its first await, so a Save clicked mid-Apply still closes the sheet.
|
||||||
|
|
||||||
**The rail means two different things, and that is deliberate:**
|
**The rail means two different things, and that is deliberate:**
|
||||||
|
|
||||||
@@ -758,7 +781,7 @@ Further detail: in the Settings section order, the version and the updater lead
|
|||||||
|
|
||||||
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
||||||
|
|
||||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the harness mark (an agent CLI's `run-mode-dot` logo, a shell's `sh` chip) do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||||
|
|
||||||
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
||||||
|
|
||||||
@@ -772,6 +795,20 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
|||||||
|
|
||||||
⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the closed drawer keeps `display: flex`, so without `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable.
|
⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the closed drawer keeps `display: flex`, so without `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable.
|
||||||
|
|
||||||
|
⚠️ **The rail's Search sessions box and the sidebar's filter box are ONE row filter** (#580): `applySidebarFilter(query)` sets `_sidebarFilter`, the rail box (`setTabRailSearch`, `#tabRailSearch` at the top of `#tabRail`) sets `_tabRailSearch`, and both run `_applyTabListFilter()`, which reads the rows the last render drew and applies the pure `CodemanTabSearch` (constants.js) as classes. Only one needle is ever live, because the rail and the sidebar never host `#sessionTabs` at the same time (`applyTabOrientation()` forces horizontal while the sidebar owns the list); off the rail and out of a reachable sidebar the needle is empty and every class comes off. The rules that keep it a view filter:
|
||||||
|
|
||||||
|
- **Classes only.** `tab-filtered-out` on a row, and on a `.tab-layout-group` or `.tab-cluster` the filter emptied; never a reorder, a re-render per keystroke, a layout write or a request, so `sessionOrder`, the Alt+N badges, the server order and the tab layout never move. Both render paths end in `applySidebarFilter()`, which re-applies it. The hide rules are layout-scoped like the sidebar's original one (`html[data-tab-orientation='vertical'] .tab-rail ...` for rows, groups and case boxes; `html[data-session-list="sidebar"] ...` for rows and case boxes), so a leaked class can never hide anything on the header strip, which has no box to clear it from.
|
||||||
|
- **Matching.** The rail matches the NAME only (`.tab-name`'s `data-full-name`, a web tab's title, never a URL or folder); the sidebar keeps its name + working directory. Trimmed, case-insensitive substring, lower-cased with `toLowerCase()`, never `toLocaleLowerCase()` (a Turkish locale lowers "API" to "apı").
|
||||||
|
- **A row with a tab alert is never hidden** (owner decision on #580, the collapsed-header promise again: a prompt waiting on you is never hidden by a view filter). The row goes into the core as `keep: true` when `tabAlerts` holds anything for it (red `action` or yellow `idle`); a kept row counts toward its section, so its group or case box stays on screen, but not toward `matchCount`, so "No sessions match" can show above a lone alerted row. Alerts come and go through `renderSessionTabs()`, whose tail re-runs the filter, so nothing else has to.
|
||||||
|
- **Counts and the tree.** A section's count shows the rows left showing during a search and is restored from the `data-total` it stashed on first touch. In the grouped tree `_tabTreeItems()` skips a hidden group whole (header included), the filter tail re-runs `_applyTabTreePositions()` and moves the one roving stop off a hidden item, but only while a search hides something or right after one changed what shows (both render paths already set them over an unfiltered tree). Only the grouped rail is a tree; the flat rail stays a tablist.
|
||||||
|
- **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes.
|
||||||
|
- **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:<id>` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects.
|
||||||
|
- **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler.
|
||||||
|
- **Drag stays on while searching** (owner decision): searching for a tab and dragging it into a group is a useful flow. A drop uses the same anchor logic relative to the visible rows, so a row can land between visible neighbours with hidden rows on either side.
|
||||||
|
- **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`.
|
||||||
|
|
||||||
|
Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.test.ts` (browser suite, not in the gate; it installs the real `setupEventListeners()` so the capture-before-inline Escape order is the shipped one).
|
||||||
|
|
||||||
⚠️ **The detailed RAIL additionally wears the home rail's CARD, and the detailed SIDEBAR deliberately does not**: the rail is an occasional, resizable list you scan, the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall — so the card rules are RAIL-SCOPED and were NOT added to the comma-grouped selectors above, which is the one-line change that would silently restyle the sidebar. Card state accents reuse `home-sessions-blink-red`/`-yellow` rather than declaring a second pair, and every state dot rule excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and the rail's are (0,5,1)+. There is no `.active` rule in that block on purpose: `.session-tab.active` already paints border/background/box-shadow `!important`.
|
⚠️ **The detailed RAIL additionally wears the home rail's CARD, and the detailed SIDEBAR deliberately does not**: the rail is an occasional, resizable list you scan, the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall — so the card rules are RAIL-SCOPED and were NOT added to the comma-grouped selectors above, which is the one-line change that would silently restyle the sidebar. Card state accents reuse `home-sessions-blink-red`/`-yellow` rather than declaring a second pair, and every state dot rule excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and the rail's are (0,5,1)+. There is no `.active` rule in that block on purpose: `.session-tab.active` already paints border/background/box-shadow `!important`.
|
||||||
|
|
||||||
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
|
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
|
||||||
@@ -780,7 +817,31 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
|||||||
|
|
||||||
### Split-pane sessions
|
### Split-pane sessions
|
||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return trLine truncated
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattenLine truncated
|
||||||
|
|
||||||
|
### Tile grid
|
||||||
|
|
||||||
|
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives, reports the cells whose sessions went away as `freed`), `restoreTileGridCells` (a stored grid as it comes back), `rankTileSessions` (the order a fresh or filling grid takes sessions in), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
|
||||||
|
|
||||||
|
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
|
||||||
|
|
||||||
|
⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame.
|
||||||
|
|
||||||
|
⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE it tears the tile down (like `wasActive`) and focuses it with `auto` before the delete request goes out; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead.
|
||||||
|
|
||||||
|
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
||||||
|
|
||||||
|
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, EXACTLY as the user left it (decision 11: `restoreTileGridCells` keeps its cells and holes, its focus and a zoom the user chose; `_openStoredTileGrid` puts back its divider sizes and count; an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), never filled to the remembered count and never trimmed to the window (`_applyTileLayout`'s automatic zoom shows the focused tile until the window fits, the arrangement kept); (b) else an open split's two sessions; (c) else the open sessions as `rankTileSessions` orders them (`_tileGridRanking`, tile-grid.js: no detached ones; WORKING first, the most recently started turn first, keyed off `lastSubmitAt` ONLY because a working pane's `lastActivityAt` is always "now"; then the ones NEEDING INPUT, `needs`/`waiting`, the red and yellow tab alerts, most recent first; then the rest by `sessionActivityAnchor`, newest first; a 0 stamp last, tab order the final tiebreak; the states are `_mobileOverviewState`'s, and a page without it falls back to tab order), the active session always included and focused; then `tileGridSetForCount` trims (b) and (c) from the end (the session to focus kept) or fills them from the ranking to the remembered count (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`). ⚠️ A stored grid's freed cells (a session gone or popped out since) are filled from the ranking first, then other empty cells, only while it holds fewer tiles than its stored `count`; a hole the user made stays. A count picked in the menu with the grid closed (`_activateTileGrid({ count })`) re-forms a stored grid to it (`reformTileCells`, the added tiles in the empty cells first). Ctrl/Cmd+click with the grid closed opens what the toggle would with the clicked session focused, never past the count: it joins while the grid holds fewer, else takes the last tile's place. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
|
||||||
|
|
||||||
|
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
||||||
|
|
||||||
|
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), each tile and each empty slot placed explicitly in its cell; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own (its own is held but refused, `dropEffect: 'none'`, no highlight), an empty slot anything. ⚠️ **The grid is cells** (owner: an empty cell can be any cell): `grid.cells` (a session id or `null` per cell, cols x rows of them) is the ONE source of truth and `grid.ids` a getter deriving the tiles in reading order, so never write `ids` (a leftover write in the non-strict `Object.assign` methods would be silently ignored). The shape comes from the tile count; within it a tile moved onto an empty cell goes there and leaves its own cell empty (`_moveTileToCell`), a removed tile leaves a hole, an added one takes the first hole or the cell a tab was dropped on (`_placeTile`), and a shape change goes through the pure `fitTileCells` (keep row and column when all fit, else pack). Each slot carries its cell (`data-cell`). The focus chords run `tileInDirection` over the CELLS (never onto a hole; the nearest tile in the nearest row for up/down), the Move Tile chords `tileCellInDirection` (the adjacent cell: into it when empty, a swap when not). Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile.
|
||||||
|
|
||||||
|
⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot <cliId>` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it.
|
||||||
|
|
||||||
|
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
|
||||||
|
|
||||||
|
⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
|
||||||
|
|
||||||
### Gesture control: the setting
|
### Gesture control: the setting
|
||||||
|
|
||||||
@@ -832,7 +893,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
|||||||
|
|
||||||
### Entrance animations
|
### Entrance animations
|
||||||
|
|
||||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section.
|
||||||
|
|
||||||
⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart.
|
⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart.
|
||||||
|
|
||||||
@@ -844,16 +905,50 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
|||||||
|
|
||||||
⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target.
|
⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target.
|
||||||
|
|
||||||
|
**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`.
|
||||||
|
|
||||||
|
⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both.
|
||||||
|
|
||||||
|
⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible.
|
||||||
|
|
||||||
|
⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says.
|
||||||
|
|
||||||
### Mobile tab strip scrolling
|
### Mobile tab strip scrolling
|
||||||
|
|
||||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
|
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
|
||||||
|
|
||||||
|
⚠️ **Grouped by state, the active tab can move without being switched to.** The strip orders its tabs in one `order` band per state (`CodemanTabTriage`), so the active session changing state (a prompt sent: idle to working; a permission prompt: needs you) slides its chip into another band while `scrollLeft` stays, and the tab in use left the screen (measured at 390px: x 165 to -870). `_noteActiveTabBand()` records the active tab's band (read off the element, also from `_updateActiveTabImmediate()`, so the pass right after a switch counts: viewing a waiting tab spends its alert and drops it to idle) and both render paths reveal it when that band moved, in the single scrolling row only (`_isScrollingTabRow()`). Keyed on the BAND, never the raw order value: another tab entering or leaving the active tab's band shifts that value by one, and another tab's move must never yank a strip the user is browsing. The incremental path reveals after `updateTabOverflowMode()`, and only from the branch that reached `_syncTabTriageChrome()`, so a pass that falls through to a full rebuild mid-loop leaves the record for the rebuild to compare. Tests: `test/tab-triage.test.ts`.
|
||||||
|
|
||||||
⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it).
|
⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it).
|
||||||
|
|
||||||
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
|
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
|
||||||
|
|
||||||
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||||
|
|
||||||
|
### Tab layouts (by state, by case, ledger)
|
||||||
|
|
||||||
|
**Tab layouts** (`tabArrangement`, per-device, default `classic`, the others opt-in; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
|
||||||
|
|
||||||
|
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, when the strip starts wrapping, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). ⚠️ Only the wrapping strip reads the label column, so `_sizeTabTriageGutter()` measures nothing (and forgets its key) while the strip does not wrap, skips parts that are not laid out (a hidden heading measures 0 per part, and the 5px gap between label and count used to turn that into a 15px column cached as if measured), and `updateTabOverflowMode()` calls it right after deciding to wrap, because a phone turned to landscape or a foldable opened crosses 768px with no tab render behind it (the resize handler only calls `updateTabOverflowMode()`). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
|
||||||
|
|
||||||
|
⚠️ **By state is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass and can still empty a group or fill a new one. Inside a header row tabs keep TAB order; a sorted rail ranks each section the way it ranks the flat rail. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap`; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width.
|
||||||
|
|
||||||
|
**By case.** Each case's tabs sit in one `.tab-cluster` box (`_renderTabClusters()`), in the order the case first appears in the tab order; membership is `_mobileOverviewCaseFor()` (longest case path that prefixes the working directory), else the directory, else the session alone. A box with two or more tabs is labelled (swatch, name, count) and its generated `w<n>-<case>` names drop the `-<case>`, which stays in the DOM in a `.tab-name-case` span only `.tabs-clusters` hides; a one-tab case gets only the swatch in the header strip; a web tab gets a box of its own. The colour is `--session-<colour>` by a stable hash of the case key (`CodemanTabClusters.colorFor()`), so nothing is stored. The rail and the sidebar draw every case as a labelled section; phones dissolve the boxes (`display: contents`) into the one chip row.
|
||||||
|
|
||||||
|
⚠️ **The boxes ARE a DOM grouping**, unlike by state, which is safe only because a tab changes case far less often than it changes state: `_tabClusterLayout().key` is the whole structure, and the incremental path rebuilds whenever it differs from the last full render's. The keyboard walk keeps boxes together (box index first, then computed order, since a sorted rail orders rows inside each box). Drag only reorders inside one box, as it only reorders inside one state group (`_isTabDropAcrossGroups()`).
|
||||||
|
|
||||||
|
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
|
||||||
|
|
||||||
|
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
|
||||||
|
|
||||||
|
### Header stats styles
|
||||||
|
|
||||||
|
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
|
||||||
|
|
||||||
|
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
|
||||||
|
|
||||||
|
⚠️ The extra parts (`.stat-ring`, `.connection-tile`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. ⚠️ A tile is a three-row grid (9px label, 14px value, 2px bar) with pixel line-heights, 36px tall like the header, in the bundled JetBrains Mono. The first version laid the bar over the bottom of a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it; the bar is now a real row, so the height comes from the layout, not the font. The WS tile keeps its grid on the inner `.connection-tile` span, because `_updateConnectionIndicator()` writes the indicator's own `display` inline. The tile words come from `_connectionTileWords()`, DERIVED from the connection descriptor rather than added to it, because `test/connection-indicator.test.ts` pins the descriptor's exact shape. Plan rings and meters clamp their fill to 0-100 while the label keeps the real number. Tests: `test/header-stats-style.test.ts`.
|
||||||
|
|
||||||
### Phone overview home screen
|
### Phone overview home screen
|
||||||
|
|
||||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
|
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
|
||||||
@@ -928,7 +1023,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
|||||||
|
|
||||||
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions.
|
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions.
|
||||||
|
|
||||||
**(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`.
|
**(1)** `_installMobileKeyboardDismiss()`, a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. ⚠️ A roving-tabindex widget defeats that arm: the grouped rail's tree parks every item but one at `tabindex=-1`, and its group headers are not buttons, so the selector lists `[role="treeitem"]` by role (pinned by `test/tab-layout-rail.test.ts` and `test/tab-activation.browser.test.ts`).
|
||||||
|
|
||||||
**(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`.
|
**(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`.
|
||||||
|
|
||||||
@@ -966,7 +1061,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
|||||||
|
|
||||||
### Z-index layers
|
### Z-index layers
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
||||||
|
|
||||||
## Security layers
|
## Security layers
|
||||||
|
|
||||||
@@ -992,6 +1087,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
|||||||
|
|
||||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||||
|
|
||||||
|
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): 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; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`.
|
||||||
|
|
||||||
### Process-tree walks are bounded
|
### Process-tree walks are bounded
|
||||||
|
|
||||||
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path).
|
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path).
|
||||||
@@ -1000,6 +1097,8 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
|||||||
|
|
||||||
⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots.
|
⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots.
|
||||||
|
|
||||||
|
⚠️ **The kill path's waits are deadlines, not sleeps** (`waitForProcessesExit()`, `utils/process-exit-wait.ts`): `Session.stop()` (PTY client, 100 ms), and `killSession()`'s children (200 ms), process group (100 ms) and final verify (2 s) each stop waiting as soon as the processes are gone. They used to be fixed sleeps plus a 100 ms verify poll, ~0.45 s per close even for a session whose processes died in a few ms. `isProcessRunning()` counts a zombie as exited (`/proc/<pid>/stat` state `Z`/`X`), since `kill(pid, 0)` cannot tell one apart and a zombie only awaits its reaper. Claude itself takes ~0.8 s to exit on SIGTERM or SIGHUP (measured 2026-10-10), so the process-group deadline still ends in SIGKILL for a claude pane, as it always did. `tmux kill-session` and the pane-pid lookup on this path are async, never `execSync` (tens of ms of a frozen server per close). `killSubagentsForSession()` makes ONE `getClaudePids()` scan for all of the session's active/idle subagents, never one `pgrep` per agent (~85 ms each with ~100 matching processes).
|
||||||
|
|
||||||
⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
|
⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
|
||||||
|
|
||||||
## Local packages and build artifacts
|
## Local packages and build artifacts
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -118,6 +128,10 @@ sure its row is one the agent cannot write.
|
|||||||
|
|
||||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||||
|
|
||||||
|
## The newline chord
|
||||||
|
|
||||||
|
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||||
|
|
||||||
## Arg-template safety
|
## Arg-template safety
|
||||||
|
|
||||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||||
@@ -249,6 +263,16 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
|||||||
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
||||||
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
||||||
|
|
||||||
|
## 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 (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`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
|
|||||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||||
|
|
||||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
|
||||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||||
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
|||||||
|
|
||||||
### In the browser
|
### In the browser
|
||||||
|
|
||||||
1. Click **⏰ Cron** in the header.
|
1. Click **⏰ Cron** in the bottom toolbar.
|
||||||
2. Click **+ New Job**.
|
2. Click **+ New Job**.
|
||||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||||
|
|||||||
@@ -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: 5.2 MiB |
|
After Width: | Height: | Size: 548 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**:
|
||||||
|
|
||||||
@@ -532,6 +533,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 10c. Webhook notifications (outbound channel)
|
||||||
|
|
||||||
|
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
|
||||||
|
|
||||||
|
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||||
|
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||||
|
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 11. Quick reference
|
## 11. Quick reference
|
||||||
|
|
||||||
| Env / flag | Effect |
|
| Env / flag | Effect |
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
**Author**: Claude (session with Tim), 2026-09-15
|
**Author**: Claude (session with Tim), 2026-09-15
|
||||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||||
|
|
||||||
|
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||||
|
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||||
|
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||||
|
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||||
|
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||||
|
> closes anything (Close Session has no default key).
|
||||||
|
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||||
@@ -84,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).
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +69,14 @@ 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 |
|
||||||
| Read My Mind | Yes | No |
|
| Read My Mind | Yes | No |
|
||||||
| Ralph loop and its task tracker | Yes | No |
|
| Ralph loop and its task tracker | Yes | No |
|
||||||
| Subagent and team windows | Yes | No |
|
| Subagent and team windows | Yes | No |
|
||||||
| Model, effort, and ultracode controls | Yes | No |
|
| Model, effort, advisor, and ultracode controls | Yes | No |
|
||||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
|
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
|
||||||
| The bundled agent skill | Yes | No |
|
| The bundled agent skill | Yes | No |
|
||||||
|
|
||||||
@@ -96,6 +96,9 @@ The defaults you will care about, all under **App Settings**:
|
|||||||
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
||||||
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
||||||
environment variable, because that would hard-lock it and block in-session switching.
|
environment variable, because that would hard-lock it and block in-session switching.
|
||||||
|
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
|
||||||
|
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
|
||||||
|
default: `/advisor` switches it or turns it off inside the session.
|
||||||
- **Startup permission mode** (Agents & CLIs section). The default is
|
- **Startup permission mode** (Agents & CLIs section). The default is
|
||||||
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
||||||
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
||||||
@@ -118,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,
|
||||||
@@ -145,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
|
||||||
@@ -166,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).
|
||||||
|
|
||||||
@@ -219,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,11 +15,12 @@ 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 |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
|
|||||||
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
|
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
|
||||||
dependencies and open a PR.*
|
dependencies and open a PR.*
|
||||||
|
|
||||||
The ⏰ **Cron** header button is opt-in. Turn it on in
|
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
|
||||||
**App Settings → Header & Panels**.
|
**App Settings → Header & Panels → Scheduling**.
|
||||||
|
|
||||||
## Creating a job
|
## Creating a job
|
||||||
|
|
||||||
@@ -96,11 +96,19 @@ The skip policy has the details you would want it to have:
|
|||||||
|
|
||||||
Every fire is recorded per job, with a status:
|
Every fire is recorded per job, with a status:
|
||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
| --------- | -------------------------------------------------------------------- |
|
| ----------------- | ----------------------------------------------------------------- |
|
||||||
| `created` | The run started and a session was created. |
|
| `created` | The run record was created; the session has not started yet. |
|
||||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
| `session_started` | The session started; prompt delivery is still pending. |
|
||||||
| `failed` | The prompt could not be resolved, or the working directory was gone. |
|
| `prompt_sent` | The prompt was sent to the session. |
|
||||||
|
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||||
|
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
|
||||||
|
|
||||||
|
History records whether the session started and the prompt was delivered, **not whether
|
||||||
|
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
|
||||||
|
If the session is closed during the readiness wait or the server restarts before
|
||||||
|
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
|
||||||
|
clients must not poll forever waiting for a terminal status.
|
||||||
|
|
||||||
The schedule is advanced **before** the session launches, so a slow start cannot cause the
|
The schedule is advanced **before** the session launches, so a slow start cannot cause the
|
||||||
same job to re-trigger.
|
same job to re-trigger.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain:
|
|||||||
|
|
||||||
Each route module documents its own endpoints in its file header.
|
Each route module documents its own endpoints in its file header.
|
||||||
|
|
||||||
|
## Cron jobs
|
||||||
|
|
||||||
|
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
|
||||||
|
versioned endpoints are:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/v1/cron/jobs` | List jobs |
|
||||||
|
| POST | `/api/v1/cron/jobs` | Create a job |
|
||||||
|
| GET | `/api/v1/cron/jobs/:id` | Read a job |
|
||||||
|
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
|
||||||
|
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
|
||||||
|
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
|
||||||
|
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
|
||||||
|
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
|
||||||
|
| GET | `/api/v1/cron/runs` | Read all accessible run history |
|
||||||
|
|
||||||
|
Responses use the envelope above: job lists/history have arrays in `data`, a
|
||||||
|
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
|
||||||
|
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
|
||||||
|
`session_started` and `prompt_sent` describe launch/delivery, not task success.
|
||||||
|
|
||||||
|
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
|
||||||
|
lists all request and response fields, validation, and ownership restrictions.
|
||||||
|
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
|
||||||
|
|
||||||
## Long-polling instead of polling
|
## Long-polling instead of polling
|
||||||
|
|
||||||
Three calls block until something happens, so an agent driving Codeman from a shell can wait
|
Three calls block until something happens, so an agent driving Codeman from a shell can wait
|
||||||
@@ -144,6 +170,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
|
|||||||
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
||||||
curl -s "$API/api/subagents" | jq # background agents
|
curl -s "$API/api/subagents" | jq # background agents
|
||||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||||
|
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||||
|
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||||
|
|
||||||
# with ID set to a session id:
|
# with ID set to a session id:
|
||||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||||
|
|||||||
@@ -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? |
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| ------------------------------- | --------------------------------------------------------------- |
|
| ------------------------------- | --------------------------------------------------------------- |
|
||||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||||
| `Ctrl+W` | Kill the active session. |
|
|
||||||
| `Ctrl+Tab` | Next session. |
|
| `Ctrl+Tab` | Next session. |
|
||||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||||
|
|
||||||
|
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||||
|
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||||
|
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||||
|
|
||||||
## Terminal
|
## Terminal
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
@@ -36,6 +39,22 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ opening the session.
|
|||||||
|
|
||||||
## The signals, cheapest first
|
## The signals, cheapest first
|
||||||
|
|
||||||
| Surface | Reaches you | Default |
|
| Surface | Reaches you | Default |
|
||||||
| ---------------------- | ------------------------------------------------- | ------- |
|
| ------------------------------ | ------------------------------------------------ | ------- |
|
||||||
| Tab alert | While the dashboard is open | On |
|
| Tab alert | While the dashboard is open | On |
|
||||||
| Browser title flash | Another tab in the same browser | On |
|
| Browser title flash | Another tab in the same browser | On |
|
||||||
| Desktop notification | Another window on the same machine | Opt-in |
|
| Desktop notification | Another window on the same machine | Opt-in |
|
||||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||||
| Approvals Inbox | One queue across every session | Opt-in |
|
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
| Approvals Inbox | One queue across every session | Opt-in |
|
||||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||||
|
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||||
|
|
||||||
## Tab alerts
|
## Tab alerts
|
||||||
|
|
||||||
@@ -60,6 +61,51 @@ Setup:
|
|||||||
|
|
||||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||||
|
|
||||||
|
## Webhooks: ntfy, Slack, Discord
|
||||||
|
|
||||||
|
**Opt-in, off by default. One channel for the whole server.**
|
||||||
|
|
||||||
|
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
|
||||||
|
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
|
||||||
|
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
|
||||||
|
and for a team channel.
|
||||||
|
|
||||||
|
It carries the same events as push: permission prompts, questions, idle sessions, session
|
||||||
|
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
|
||||||
|
is included only when **Which events** is set to **Everything**; the default, **Needs
|
||||||
|
attention**, skips it. A session that is watching its own work stays quiet here too.
|
||||||
|
|
||||||
|
Setup, in **App Settings → Notifications → Webhook**:
|
||||||
|
|
||||||
|
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
|
||||||
|
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
|
||||||
|
sessionId, sessionName, host, at }`.
|
||||||
|
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
|
||||||
|
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
|
||||||
|
Send test saves anything you changed first, so it always tests what is on screen.
|
||||||
|
|
||||||
|
The status line under the group shows the last delivery: when it worked, or why it did not
|
||||||
|
(an HTTP status, a timeout, a refused connection).
|
||||||
|
|
||||||
|
Behaviour worth knowing:
|
||||||
|
|
||||||
|
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
|
||||||
|
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
|
||||||
|
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
|
||||||
|
never shows it again: once saved, the box is empty and the hint shows only the scheme and
|
||||||
|
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
|
||||||
|
(which also turns the channel off).
|
||||||
|
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
|
||||||
|
the only thing keeping strangers out.
|
||||||
|
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
|
||||||
|
Link-local and cloud-metadata addresses are refused, both when you save and when the
|
||||||
|
message is sent, and redirects are not followed.
|
||||||
|
- **Repeats are folded.** The same event for the same session within three seconds is sent
|
||||||
|
once, so a flapping prompt cannot flood a channel.
|
||||||
|
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
|
||||||
|
the webhook, and it receives every user's session events (session names, tool names, error
|
||||||
|
text). Point it somewhere every user would be comfortable with.
|
||||||
|
|
||||||
## The Approvals Inbox
|
## The Approvals Inbox
|
||||||
|
|
||||||
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
||||||
@@ -152,7 +198,8 @@ It is the morning-after view for an overnight run. Enable its header button in
|
|||||||
## Recommended setup for unattended runs
|
## Recommended setup for unattended runs
|
||||||
|
|
||||||
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
||||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
|
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
|
||||||
|
webhook to ntfy if no browser will ever be open.
|
||||||
3. Approvals Inbox on.
|
3. Approvals Inbox on.
|
||||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||||
[Keeping Agents Running](Keeping-Agents-Running).
|
[Keeping Agents Running](Keeping-Agents-Running).
|
||||||
@@ -162,7 +209,8 @@ from the lock screen.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
|
||||||
|
has no such requirement, since the server sends it.
|
||||||
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
||||||
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
|
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
|
||||||
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
|||||||
|
|
||||||
| Tab | Use it when |
|
| Tab | Use it when |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
|
||||||
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
|||||||
| To do this | Do that |
|
| To do this | Do that |
|
||||||
| ------------------------- | ------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------------------------------------------- |
|
||||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||||
| Stop everything | `tmux -L codeman kill-server`. |
|
| Stop everything | `tmux -L codeman kill-server`. |
|
||||||
|
|
||||||
|
|||||||
@@ -50,20 +50,37 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
|||||||
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
||||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||||
|
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
|
||||||
|
|
||||||
### Header & Panels
|
### Header & Panels
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
**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
|
||||||
|
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
|
||||||
|
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
|
||||||
|
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, 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.
|
||||||
@@ -73,27 +90,48 @@ 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, and the thinking effort segment. The cards
|
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
||||||
and the switch compose into one model choice, so there is no separate "which one wins"
|
advisor segment. The cards and the switch compose into one model choice, so there is no
|
||||||
question.
|
separate "which one wins" question.
|
||||||
|
|
||||||
Model and effort are both **soft defaults**: the model is written into the case's
|
Model, effort and advisor are all **soft defaults**: the model is written into the case's
|
||||||
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
|
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
|
||||||
inside a session override them at any time.
|
`/effort` and `/advisor` inside a session override them at any time.
|
||||||
|
|
||||||
|
**Advisor** gives new Claude sessions Claude Code's
|
||||||
|
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
|
||||||
|
consults before committing to an approach, when an error keeps coming back, and before it
|
||||||
|
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
|
||||||
|
which costs less than running the stronger model all the time. **Default** leaves it to
|
||||||
|
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
|
||||||
|
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
|
||||||
|
attached.
|
||||||
|
|
||||||
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
||||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||||
@@ -110,13 +148,21 @@ 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) 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, and the idle
|
Master toggle, browser notifications, push subscription, audio alerts, how long a
|
||||||
threshold that decides when a quiet session counts as needing you. See
|
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
|
||||||
|
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).
|
||||||
|
|
||||||
### Voice
|
### Voice
|
||||||
@@ -133,8 +179,10 @@ 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. In multi-user mode, the
|
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||||
**Users** administration entry is injected here.
|
`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
|
||||||
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|
||||||
## Session Options
|
## Session Options
|
||||||
|
|
||||||
@@ -169,6 +217,8 @@ Some things are configured before the server starts, not in the UI:
|
|||||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||||
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
||||||
|
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
|
||||||
|
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -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. 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 |
|
||||||
@@ -64,7 +102,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
|||||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||||
| Close | `Ctrl+W` |
|
| Close | The tab's close control (no key by default) |
|
||||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||||
|
|
||||||
Tabs can also be dragged to reorder.
|
Tabs can also be dragged to reorder.
|
||||||
@@ -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,12 +161,33 @@ 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).
|
||||||
|
|
||||||
|
## Bottom bar
|
||||||
|
|
||||||
|
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
|
||||||
|
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
|
||||||
|
Panels → Bottom bar**. Click it for the Git window. See
|
||||||
|
[Working With Files](Working-With-Files#git-changes).
|
||||||
|
|
||||||
## Connection state
|
## Connection state
|
||||||
|
|
||||||
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
||||||
@@ -158,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
|
||||||
@@ -192,7 +258,7 @@ Two extras depending on the device:
|
|||||||
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
|
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
|
||||||
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
|
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
|
||||||
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
|
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
|
||||||
| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||||
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
|
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
|
||||||
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
|
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
|
||||||
| File Viewer | Header | [Working With Files](Working-With-Files) |
|
| File Viewer | Header | [Working With Files](Working-With-Files) |
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
@@ -163,6 +164,44 @@ HEIC images from an iPhone are converted to JPEG on the way in.
|
|||||||
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
|
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
|
||||||
surface as an artifact attachment rather than a path you have to go and find.
|
surface as an artifact attachment rather than a path you have to go and find.
|
||||||
|
|
||||||
|
## Git changes
|
||||||
|
|
||||||
|
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
|
||||||
|
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
|
||||||
|
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
|
||||||
|
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
|
||||||
|
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
|
||||||
|
not on remote"), which counts every commit on no remote rather than showing a green tick.
|
||||||
|
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
|
||||||
|
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
|
||||||
|
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
|
||||||
|
list of full paths instead.
|
||||||
|
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
|
||||||
|
coloured. Staged files show index versus last commit, not-staged files show working tree
|
||||||
|
versus index, untracked files show as all additions and deleted files as all removals.
|
||||||
|
**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.
|
||||||
|
- A session folder that holds several projects gets one collapsible section per repository
|
||||||
|
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
|
||||||
|
in your home folder) is ignored.
|
||||||
|
|
||||||
|
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
|
||||||
|
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
|
||||||
|
workspace is skipped even from a local session (a container can write there, and git would run
|
||||||
|
that repository's own configuration on the host). The
|
||||||
|
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
|
||||||
|
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||||
|
|||||||
@@ -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.33.3",
|
"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,
|
||||||
@@ -105,6 +105,7 @@
|
|||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"smol-toml": "^1.9.0",
|
||||||
"undici": "^6.28.0",
|
"undici": "^6.28.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
@@ -128,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",
|
||||||
@@ -168,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,79 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6aecc3b: ### Thanks
|
||||||
|
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||||
|
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||||
|
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||||
|
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||||
|
|
||||||
|
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||||
|
|
||||||
|
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||||
|
|
||||||
|
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||||
|
|
||||||
|
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||||
|
|
||||||
|
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||||
|
|
||||||
|
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||||
|
|
||||||
|
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||||
|
|
||||||
|
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ terminal.onData((data) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
|
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
|
||||||
terminal.onWriteParsed(() => {
|
// Unconditional: rerender() is a no-op when there is nothing to draw, and
|
||||||
if (zerolag.hasPending) zerolag.rerender();
|
// hasPending would miss an overlay that shows only an IME composition.
|
||||||
});
|
terminal.onWriteParsed(() => zerolag.rerender());
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the whole integration. Everything below is for tuning it.
|
That is the whole integration. Everything below is for tuning it.
|
||||||
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
|
|||||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||||
```
|
```
|
||||||
|
|
||||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,8 +202,9 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
|
||||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||||
|
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
|
||||||
|
|
||||||
### Backspace handling
|
### Backspace handling
|
||||||
|
|
||||||
@@ -217,6 +218,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
|||||||
|
|
||||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||||
|
|
||||||
|
### IME composition
|
||||||
|
|
||||||
|
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const textarea = terminal.textarea!;
|
||||||
|
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
|
||||||
|
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
|
||||||
|
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
|
||||||
|
```
|
||||||
|
|
||||||
|
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
|
||||||
|
|
||||||
### Flushed text
|
### Flushed text
|
||||||
|
|
||||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||||
@@ -242,7 +256,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
|||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
|
||||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||||
|
|
||||||
### Prompt
|
### Prompt
|
||||||
@@ -258,7 +272,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
|||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|------|-------------|
|
||||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
|
||||||
|
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
|
||||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
@@ -522,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.3.1",
|
"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",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
|||||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||||
const {
|
const {
|
||||||
lines,
|
lines,
|
||||||
|
compositionStart,
|
||||||
startCol,
|
startCol,
|
||||||
totalCols,
|
totalCols,
|
||||||
cellW,
|
cellW,
|
||||||
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||||
// dropped along with that line when the tail is all that fits.
|
// dropped along with that line when the tail is all that fits.
|
||||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||||
|
// Code-point offset of each line in the whole text, so the composition
|
||||||
|
// styling survives the tail slice below.
|
||||||
|
const lineOffsets: number[] = [];
|
||||||
|
{
|
||||||
|
let offset = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
lineOffsets.push(offset);
|
||||||
|
offset += [...line].length;
|
||||||
|
}
|
||||||
|
}
|
||||||
let visibleLines = lines;
|
let visibleLines = lines;
|
||||||
|
let firstVisible = 0;
|
||||||
let keepsPromptLine = true;
|
let keepsPromptLine = true;
|
||||||
let topRow = promptRow;
|
let topRow = promptRow;
|
||||||
if (rows && rows > 0) {
|
if (rows && rows > 0) {
|
||||||
if (lines.length > rows) {
|
if (lines.length > rows) {
|
||||||
visibleLines = lines.slice(lines.length - rows);
|
firstVisible = lines.length - rows;
|
||||||
|
visibleLines = lines.slice(firstVisible);
|
||||||
keepsPromptLine = false;
|
keepsPromptLine = false;
|
||||||
topRow = 0;
|
topRow = 0;
|
||||||
} else if (promptRow + lines.length > rows) {
|
} else if (promptRow + lines.length > rows) {
|
||||||
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
const leftPx = indents ? startCol * cellW : 0;
|
const leftPx = indents ? startCol * cellW : 0;
|
||||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||||
const topPx = i * cellH;
|
const topPx = i * cellH;
|
||||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
const lineCompositionFrom =
|
||||||
|
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||||
|
const lineEl = makeLine(
|
||||||
|
visibleLines[i],
|
||||||
|
leftPx,
|
||||||
|
topPx,
|
||||||
|
widthPx,
|
||||||
|
cellH,
|
||||||
|
cellW,
|
||||||
|
charTop,
|
||||||
|
charHeight,
|
||||||
|
font,
|
||||||
|
terminal,
|
||||||
|
lineCompositionFrom
|
||||||
|
);
|
||||||
container.appendChild(lineEl);
|
container.appendChild(lineEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
* Create a styled line `<div>` with per-character grid positioning.
|
* Create a styled line `<div>` with per-character grid positioning.
|
||||||
*
|
*
|
||||||
* Each character gets its own `<span>` positioned by visual column offset.
|
* Each character gets its own `<span>` positioned by visual column offset.
|
||||||
* CJK wide characters occupy 2 cell widths.
|
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||||
|
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||||
|
* composition text: underlined, like xterm's own composition view, and marked
|
||||||
|
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||||
*/
|
*/
|
||||||
function makeLine(
|
function makeLine(
|
||||||
text: string,
|
text: string,
|
||||||
@@ -156,7 +186,8 @@ function makeLine(
|
|||||||
_charTop: number,
|
_charTop: number,
|
||||||
_charHeight: number,
|
_charHeight: number,
|
||||||
font: FontStyle,
|
font: FontStyle,
|
||||||
terminal?: XtermTerminal | null
|
terminal?: XtermTerminal | null,
|
||||||
|
compositionFrom?: number
|
||||||
): HTMLDivElement {
|
): HTMLDivElement {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||||
@@ -172,6 +203,7 @@ function makeLine(
|
|||||||
|
|
||||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||||
let colOffset = 0;
|
let colOffset = 0;
|
||||||
|
let index = 0;
|
||||||
for (const ch of text) {
|
for (const ch of text) {
|
||||||
const cw = charCellWidth(terminal, ch);
|
const cw = charCellWidth(terminal, ch);
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
@@ -189,9 +221,15 @@ function makeLine(
|
|||||||
span.style.fontWeight = font.fontWeight;
|
span.style.fontWeight = font.fontWeight;
|
||||||
span.style.color = font.color;
|
span.style.color = font.color;
|
||||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||||
|
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||||
|
span.style.textDecoration = 'underline';
|
||||||
|
span.setAttribute('data-zerolag-composition', '');
|
||||||
|
span.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
span.textContent = ch;
|
span.textContent = ch;
|
||||||
el.appendChild(span);
|
el.appendChild(span);
|
||||||
colOffset += cw;
|
colOffset += cw;
|
||||||
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,12 @@ export interface CellDimensions {
|
|||||||
/** Parameters for the overlay renderer. */
|
/** Parameters for the overlay renderer. */
|
||||||
export interface RenderParams {
|
export interface RenderParams {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
|
/**
|
||||||
|
* Index (in code points, across all `lines`) where IME composition text
|
||||||
|
* begins. Characters from there on are drawn underlined and marked
|
||||||
|
* `data-zerolag-composition`. Omit when nothing is being composed.
|
||||||
|
*/
|
||||||
|
compositionStart?: number;
|
||||||
startCol: number;
|
startCol: number;
|
||||||
totalCols: number;
|
totalCols: number;
|
||||||
cellW: number;
|
cellW: number;
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
private _flushedOffset = 0;
|
private _flushedOffset = 0;
|
||||||
private _flushedText = '';
|
private _flushedText = '';
|
||||||
private _bufferDetectDone = false;
|
private _bufferDetectDone = false;
|
||||||
|
// IME text still being composed: drawn after the pending text, never sent.
|
||||||
|
private _composition = '';
|
||||||
|
|
||||||
// Render cache
|
// Render cache
|
||||||
private _lastRenderKey = '';
|
private _lastRenderKey = '';
|
||||||
@@ -120,17 +122,16 @@ 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);
|
||||||
this._scrollTimer = null;
|
this._scrollTimer = null;
|
||||||
}
|
}
|
||||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
} else if (this._hasContent()) {
|
||||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||||
this._scrollTimer = setTimeout(() => {
|
this._scrollTimer = setTimeout(() => {
|
||||||
this._scrollTimer = null;
|
this._scrollTimer = null;
|
||||||
@@ -206,8 +207,14 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
||||||
* The consumer SHOULD send backspace to the PTY.
|
* The consumer SHOULD send backspace to the PTY.
|
||||||
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
||||||
|
*
|
||||||
|
* Any IME composition is dropped in every case, and the overlay is repainted
|
||||||
|
* without it (hidden when nothing else is left).
|
||||||
*/
|
*/
|
||||||
removeChar(): 'pending' | 'flushed' | false {
|
removeChar(): 'pending' | 'flushed' | false {
|
||||||
|
// A backspace that reaches the overlay means no composition is open.
|
||||||
|
const droppedComposition = this._composition.length > 0;
|
||||||
|
this._composition = '';
|
||||||
if (this._pendingText.length > 0) {
|
if (this._pendingText.length > 0) {
|
||||||
this._pendingText = this._pendingText.slice(0, -1);
|
this._pendingText = this._pendingText.slice(0, -1);
|
||||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||||
@@ -243,6 +250,9 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
return 'flushed';
|
return 'flushed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing to remove, but a composition-only overlay is still on screen
|
||||||
|
// drawing the text dropped above.
|
||||||
|
if (droppedComposition) this._hide();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +262,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
*/
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this._pendingText = '';
|
this._pendingText = '';
|
||||||
|
this._composition = '';
|
||||||
this._flushedOffset = 0;
|
this._flushedOffset = 0;
|
||||||
this._flushedText = '';
|
this._flushedText = '';
|
||||||
this._bufferDetectDone = false;
|
this._bufferDetectDone = false;
|
||||||
@@ -297,7 +308,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
clearFlushed(): void {
|
clearFlushed(): void {
|
||||||
this._flushedOffset = 0;
|
this._flushedOffset = 0;
|
||||||
this._flushedText = '';
|
this._flushedText = '';
|
||||||
if (this._pendingText) {
|
if (this._pendingText || this._composition) {
|
||||||
this._render();
|
this._render();
|
||||||
} else {
|
} else {
|
||||||
this._hide();
|
this._hide();
|
||||||
@@ -312,7 +323,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
* that move the prompt.
|
* that move the prompt.
|
||||||
*/
|
*/
|
||||||
rerender(): void {
|
rerender(): void {
|
||||||
if (this._pendingText || this._flushedOffset > 0) {
|
if (this._hasContent()) {
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
@@ -325,7 +336,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
refreshFont(): void {
|
refreshFont(): void {
|
||||||
this._cacheFont();
|
this._cacheFont();
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
if (this._hasContent()) this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Buffer detection ─────────────────────────────────────────────
|
// ─── Buffer detection ─────────────────────────────────────────────
|
||||||
@@ -391,7 +402,37 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
this._options.prompt = finder;
|
this._options.prompt = finder;
|
||||||
this._lastPromptPos = null;
|
this._lastPromptPos = null;
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
if (this._hasContent()) this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IME composition ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show text an IME is still composing as an underlined tail after the
|
||||||
|
* pending text, wrapped and kept on screen like the rest of the overlay.
|
||||||
|
* Pass `''` to remove it.
|
||||||
|
*
|
||||||
|
* Visual only: the composition is never part of `pendingText`, `hasPending`
|
||||||
|
* or anything a consumer sends. When the IME commits, the consumer adds the
|
||||||
|
* committed text the usual way (`addChar`/`appendText`) and clears the
|
||||||
|
* composition. `clear()` and `removeChar()` drop it too.
|
||||||
|
*/
|
||||||
|
setComposition(text: string): void {
|
||||||
|
// One visual line of provisional text: control characters and line breaks
|
||||||
|
// would break the cell grid.
|
||||||
|
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
|
||||||
|
if (next === this._composition) return;
|
||||||
|
this._composition = next;
|
||||||
|
if (this._hasContent()) {
|
||||||
|
this._render();
|
||||||
|
} else {
|
||||||
|
this._hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
|
||||||
|
get composition(): string {
|
||||||
|
return this._composition;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||||
@@ -425,7 +466,13 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
return this._pendingText;
|
return this._pendingText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether there is any overlay content (pending or flushed). */
|
/**
|
||||||
|
* Whether there is pending or flushed text. Excludes the IME composition,
|
||||||
|
* which is never sent, so an overlay showing only a composition reports
|
||||||
|
* `false` while still on screen. To re-place the overlay after output or a
|
||||||
|
* resize, call `rerender()` unconditionally: it is a no-op when there is
|
||||||
|
* nothing to draw.
|
||||||
|
*/
|
||||||
get hasPending(): boolean {
|
get hasPending(): boolean {
|
||||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||||
}
|
}
|
||||||
@@ -443,6 +490,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
// ─── Private methods ──────────────────────────────────────────────
|
// ─── Private methods ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private _hasContent(): boolean {
|
||||||
|
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private _getPromptOffset(): number {
|
private _getPromptOffset(): number {
|
||||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||||
return prompt.offset ?? 2;
|
return prompt.offset ?? 2;
|
||||||
@@ -505,7 +556,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
private _render(): void {
|
private _render(): void {
|
||||||
if (!this._terminal || !this._overlay) return;
|
if (!this._terminal || !this._overlay) return;
|
||||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
if (!this._hasContent()) {
|
||||||
this._overlay.style.display = 'none';
|
this._overlay.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -513,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;
|
||||||
}
|
}
|
||||||
@@ -563,12 +614,16 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The composition is a styled tail after everything the user has typed.
|
||||||
|
const compositionStart = [...displayText].length;
|
||||||
|
displayText += this._composition;
|
||||||
|
|
||||||
// Skip redundant re-renders — include text content to detect
|
// Skip redundant re-renders — include text content to detect
|
||||||
// same-length changes (e.g., setFlushed with different text)
|
// same-length changes (e.g., setFlushed with different text)
|
||||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||||
// changing the text — must not be skipped as a redundant render.
|
// changing the text — must not be skipped as a redundant render.
|
||||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||||
this._lastRenderKey = renderKey;
|
this._lastRenderKey = renderKey;
|
||||||
|
|
||||||
@@ -608,6 +663,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
renderOverlay(this._overlay, {
|
renderOverlay(this._overlay, {
|
||||||
lines,
|
lines,
|
||||||
|
compositionStart: this._composition ? compositionStart : undefined,
|
||||||
startCol,
|
startCol,
|
||||||
totalCols,
|
totalCols,
|
||||||
cellW,
|
cellW,
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { createMockTerminal } from './helpers.js';
|
||||||
|
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||||
|
|
||||||
|
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||||
|
// after the pending text. Visual only, never part of what a consumer sends.
|
||||||
|
|
||||||
|
const CELL_W = 10;
|
||||||
|
|
||||||
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const fn of cleanups) fn();
|
||||||
|
cleanups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
|
||||||
|
const mock = createMockTerminal({
|
||||||
|
buffer: { lines: opts.lines ?? ['$ '] },
|
||||||
|
cols: opts.cols,
|
||||||
|
rows: opts.rows,
|
||||||
|
cellWidth: CELL_W,
|
||||||
|
cellHeight: 20,
|
||||||
|
});
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
|
||||||
|
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
|
||||||
|
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineText(line: HTMLDivElement): string {
|
||||||
|
return Array.from(line.children)
|
||||||
|
.map((s) => s.textContent)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function compositionText(overlay: HTMLDivElement): string {
|
||||||
|
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
|
||||||
|
.map((s) => s.textContent)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setComposition', () => {
|
||||||
|
it('renders the composition after pendingText, underlined and aria-hidden', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
|
||||||
|
const [line] = lineDivs(overlay);
|
||||||
|
expect(lineText(line)).toBe('abcxy');
|
||||||
|
const spans = Array.from(line.children) as HTMLSpanElement[];
|
||||||
|
for (const span of spans.slice(0, 3)) {
|
||||||
|
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
|
||||||
|
expect(span.style.textDecoration).toBe('');
|
||||||
|
}
|
||||||
|
for (const span of spans.slice(3)) {
|
||||||
|
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
|
||||||
|
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(span.style.textDecoration).toBe('underline');
|
||||||
|
}
|
||||||
|
// Grid positions continue straight on from the pending text.
|
||||||
|
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||||
|
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||||
|
expect(overlay.style.display).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a wide composition by cell width after wide pending text', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('今日は');
|
||||||
|
addon.setComposition('天気');
|
||||||
|
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||||
|
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||||
|
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||||
|
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||||
|
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||||
|
const { addon } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setFlushed(2, 'zz');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(addon.pendingText).toBe('abc');
|
||||||
|
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
|
||||||
|
expect(addon.composition).toBe('xy');
|
||||||
|
expect(addon.state.pendingText).toBe('abc');
|
||||||
|
expect(addon.state.flushedText).toBe('zz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows on an empty prompt without making anything pending', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('かな');
|
||||||
|
expect(addon.pendingText).toBe('');
|
||||||
|
expect(addon.hasPending).toBe(false);
|
||||||
|
expect(addon.state.visible).toBe(true);
|
||||||
|
expect(compositionText(overlay)).toBe('かな');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps with the pending text: the tail continues onto the next line', () => {
|
||||||
|
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
|
||||||
|
const { addon, overlay } = setup({ cols: 12 });
|
||||||
|
addon.appendText('abcdefgh');
|
||||||
|
addon.setComposition('WXYZ');
|
||||||
|
const lines = lineDivs(overlay);
|
||||||
|
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
|
||||||
|
expect(compositionText(overlay)).toBe('WXYZ');
|
||||||
|
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||||
|
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||||
|
expect(second[0].style.left).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||||
|
// 2 visible rows, 3 lines of text: the first line is dropped.
|
||||||
|
const { addon, overlay } = setup({ cols: 6, rows: 2 });
|
||||||
|
addon.appendText('abcdefghij');
|
||||||
|
addon.setComposition('XYZ');
|
||||||
|
const lines = lineDivs(overlay);
|
||||||
|
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
|
||||||
|
expect(compositionText(overlay)).toBe('XYZ');
|
||||||
|
const first = Array.from(lines[0].children);
|
||||||
|
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setComposition('') removes the tail and keeps the pending text", () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.setComposition('');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
|
||||||
|
expect(compositionText(overlay)).toBe('');
|
||||||
|
expect(addon.pendingText).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setComposition('') on an otherwise empty overlay hides it", () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.setComposition('');
|
||||||
|
expect(overlay.style.display).toBe('none');
|
||||||
|
expect(overlay.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.clear();
|
||||||
|
expect(addon.composition).toBe('');
|
||||||
|
expect(overlay.style.display).toBe('none');
|
||||||
|
addon.addChar('q');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('q');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(addon.removeChar()).toBe('pending');
|
||||||
|
expect(addon.pendingText).toBe('ab');
|
||||||
|
expect(addon.composition).toBe('');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('ka');
|
||||||
|
expect(compositionText(overlay)).toBe('ka');
|
||||||
|
expect(addon.removeChar()).toBe(false);
|
||||||
|
expect(addon.composition).toBe('');
|
||||||
|
expect(compositionText(overlay)).toBe('');
|
||||||
|
expect(overlay.style.display).toBe('none');
|
||||||
|
expect(addon.state.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar() repaints flushed text without the dropped composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setFlushed(3, 'abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(addon.removeChar()).toBe('flushed');
|
||||||
|
expect(compositionText(overlay)).toBe('');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text appended while composing lands before the tail', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('ab');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.addChar('c');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||||
|
expect(compositionText(overlay)).toBe('xy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rerender() and refreshFont() keep the composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.rerender();
|
||||||
|
expect(compositionText(overlay)).toBe('xy');
|
||||||
|
addon.refreshFont();
|
||||||
|
expect(compositionText(overlay)).toBe('xy');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-renders when only the composition changes', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('x');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips control characters and line breaks from the composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('a\nb\u0007c
');
|
||||||
|
expect(addon.composition).toBe('abc');
|
||||||
|
expect(compositionText(overlay)).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the block cursor after the composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('ab');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||||
|
// prompt col 0 + offset 2 + 4 cells
|
||||||
|
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.33.3",
|
"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
|
||||||
@@ -47,7 +53,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +81,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -196,6 +202,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -207,12 +218,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -372,10 +390,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -426,7 +444,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -493,6 +511,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
|
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||||
|
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||||
|
stronger model it consults before committing to an approach, on a recurring error and
|
||||||
|
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||||
|
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||||
|
`fable` do anything).
|
||||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -129,12 +134,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -294,4 +306,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
@@ -345,6 +345,13 @@ ESC=$(printf '\033')
|
|||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
|
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||||
|
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||||
|
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||||
|
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||||
|
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||||
|
`CODEMAN_WORKER_ADVISOR` is set.
|
||||||
|
|
||||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
@@ -384,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
|
|||||||
|
|
||||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||||
`envOverrides`). Three differences that break copied code:
|
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||||
|
differences that break copied code:
|
||||||
|
|
||||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||||
(`session-routes.ts:648`).
|
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||||
|
|
||||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
|
|||||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||||
|
one that does not answer or cannot be read (an unreachable network mount, a
|
||||||
|
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||||
|
create a replacement for it;
|
||||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||||
`SESSION_BUSY` for the identical condition.
|
`SESSION_BUSY` for the identical condition.
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -83,9 +113,11 @@ appendFileSync(
|
|||||||
|
|
||||||
// 4. Minify frontend assets
|
// 4. Minify frontend assets
|
||||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||||
|
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
|
||||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||||
|
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
|
||||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||||
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
||||||
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
||||||
@@ -111,8 +143,10 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'notification-manager.js',
|
'notification-manager.js',
|
||||||
'keyboard-accessory.js',
|
'keyboard-accessory.js',
|
||||||
'input-cjk.js',
|
'input-cjk.js',
|
||||||
|
'mobile-ime-preview.js',
|
||||||
'terminal-keycode229-recovery.js',
|
'terminal-keycode229-recovery.js',
|
||||||
'sanitize-html.js',
|
'sanitize-html.js',
|
||||||
|
'tab-layout-browser.js',
|
||||||
'app.js',
|
'app.js',
|
||||||
'tab-rail-resize.js',
|
'tab-rail-resize.js',
|
||||||
'terminal-ui.js',
|
'terminal-ui.js',
|
||||||
@@ -125,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
|
||||||
@@ -47,7 +53,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +81,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -196,6 +202,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -207,12 +218,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -372,10 +390,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -426,7 +444,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -493,6 +511,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
|
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||||
|
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||||
|
stronger model it consults before committing to an approach, on a recurring error and
|
||||||
|
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||||
|
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||||
|
`fable` do anything).
|
||||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -129,12 +134,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -294,4 +306,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
@@ -345,6 +345,13 @@ ESC=$(printf '\033')
|
|||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
|
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||||
|
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||||
|
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||||
|
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||||
|
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||||
|
`CODEMAN_WORKER_ADVISOR` is set.
|
||||||
|
|
||||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
@@ -384,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
|
|||||||
|
|
||||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||||
`envOverrides`). Three differences that break copied code:
|
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||||
|
differences that break copied code:
|
||||||
|
|
||||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||||
(`session-routes.ts:648`).
|
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||||
|
|
||||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
|
|||||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||||
|
one that does not answer or cannot be read (an unreachable network mount, a
|
||||||
|
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||||
|
create a replacement for it;
|
||||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||||
`SESSION_BUSY` for the identical condition.
|
`SESSION_BUSY` for the identical condition.
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +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 { 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
|
||||||
@@ -27,6 +28,14 @@ const envName = z
|
|||||||
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
|
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
|
||||||
.max(64);
|
.max(64);
|
||||||
|
|
||||||
|
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
|
||||||
|
const mcpRelativePath = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
||||||
|
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
|
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
|
||||||
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
|
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
|
||||||
@@ -280,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() })
|
||||||
@@ -311,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),
|
||||||
@@ -361,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
|
||||||
@@ -377,6 +438,27 @@ const capabilitiesSchema = z
|
|||||||
privilegedEnvKeys: z.array(envName).max(8),
|
privilegedEnvKeys: z.array(envName).max(8),
|
||||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||||
maxFrameBytes: z.number().int().positive().optional(),
|
maxFrameBytes: z.number().int().positive().optional(),
|
||||||
|
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
||||||
|
mcpConfig: z
|
||||||
|
.object({
|
||||||
|
// Home-relative, no traversal: sync writes to this path.
|
||||||
|
path: mcpRelativePath,
|
||||||
|
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||||
|
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||||
|
format: z.enum([
|
||||||
|
'claude-json',
|
||||||
|
'gemini-json',
|
||||||
|
'codex-toml',
|
||||||
|
'opencode-json',
|
||||||
|
'antigravity-json',
|
||||||
|
'copilot-json',
|
||||||
|
] as const satisfies readonly McpConfigFormat[]),
|
||||||
|
// The env var the CLI reads to move the file, and the path under it (same no-traversal
|
||||||
|
// rule: sync writes there too). Resolved from the server env at call time, never here.
|
||||||
|
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
customModelInjection: z.discriminatedUnion('kind', [
|
customModelInjection: z.discriminatedUnion('kind', [
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -559,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({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CliEntry } from './types.js';
|
import type { CliEntry } from './types.js';
|
||||||
|
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
|
||||||
|
|
||||||
const HOME_DIRS = {
|
const HOME_DIRS = {
|
||||||
local: '~/.local/bin',
|
local: '~/.local/bin',
|
||||||
@@ -217,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.
|
||||||
@@ -306,6 +310,12 @@ const CLAUDE: CliEntry = {
|
|||||||
'CLAUDE_CONFIG_DIR',
|
'CLAUDE_CONFIG_DIR',
|
||||||
],
|
],
|
||||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||||
|
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.claude.json',
|
||||||
|
format: 'claude-json',
|
||||||
|
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||||
|
},
|
||||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
||||||
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
||||||
// respawn, never a live hot-swap.
|
// respawn, never a live hot-swap.
|
||||||
@@ -484,8 +494,51 @@ 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`.
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.config/opencode/opencode.json',
|
||||||
|
format: 'opencode-json',
|
||||||
|
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||||
|
},
|
||||||
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
||||||
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
||||||
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
||||||
@@ -532,6 +585,7 @@ const CODEX: CliEntry = {
|
|||||||
bypassApprovals: { type: 'bool' },
|
bypassApprovals: { type: 'bool' },
|
||||||
animations: { type: 'bool' },
|
animations: { type: 'bool' },
|
||||||
model: { type: 'token', pattern: 'model' },
|
model: { type: 'token', pattern: 'model' },
|
||||||
|
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||||
resumeId: { type: 'token', pattern: 'id' },
|
resumeId: { type: 'token', pattern: 'id' },
|
||||||
},
|
},
|
||||||
variants: [
|
variants: [
|
||||||
@@ -543,6 +597,14 @@ const CODEX: CliEntry = {
|
|||||||
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
|
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
|
||||||
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
|
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
|
||||||
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||||
|
// One literal per level: an argv token cannot splice a value into a literal, and
|
||||||
|
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
|
||||||
|
// what admits a level, so an unknown one emits nothing.
|
||||||
|
...CODEX_REASONING_EFFORTS.map((level) => ({
|
||||||
|
flag: '--config',
|
||||||
|
value: `model_reasoning_effort=${level}`,
|
||||||
|
when: { param: 'reasoningEffort', is: level },
|
||||||
|
})),
|
||||||
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
|
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
|
||||||
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||||
],
|
],
|
||||||
@@ -576,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
|
||||||
@@ -598,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,
|
||||||
@@ -620,6 +716,11 @@ const CODEX: CliEntry = {
|
|||||||
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
||||||
// regression; `schema.ts` now rejects a name that is not a declared param.
|
// regression; `schema.ts` now rejects a name that is not a declared param.
|
||||||
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.codex/config.toml',
|
||||||
|
format: 'codex-toml',
|
||||||
|
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||||
|
},
|
||||||
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
||||||
// so the user's real ~/.codex/config.toml is never touched.
|
// so the user's real ~/.codex/config.toml is never touched.
|
||||||
customModelInjection: {
|
customModelInjection: {
|
||||||
@@ -717,10 +818,38 @@ 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.
|
||||||
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
||||||
|
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.gemini/settings.json',
|
||||||
|
format: 'gemini-json',
|
||||||
|
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||||
|
},
|
||||||
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
||||||
// start). Confirm the exact model-override env var name against the installed
|
// start). Confirm the exact model-override env var name against the installed
|
||||||
// gemini-cli version before shipping.
|
// gemini-cli version before shipping.
|
||||||
@@ -800,6 +929,8 @@ const ANTIGRAVITY: CliEntry = {
|
|||||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||||
// SENT config needs the flag forced off — nothing is materialized.
|
// SENT config needs the flag forced off — nothing is materialized.
|
||||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||||
|
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
|
||||||
|
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
|
||||||
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
||||||
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
||||||
// reasoning model. Toolbar entry stays disabled for this mode.
|
// reasoning model. Toolbar entry stays disabled for this mode.
|
||||||
@@ -890,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.
|
||||||
@@ -1007,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.
|
||||||
@@ -1182,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.
|
||||||
@@ -1312,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: [],
|
||||||
|
|||||||
@@ -90,6 +90,29 @@ export interface CliVariant {
|
|||||||
args: ArgSpec[];
|
args: ArgSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||||
|
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. */
|
||||||
|
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>;
|
||||||
/**
|
/**
|
||||||
@@ -356,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`.
|
||||||
*/
|
*/
|
||||||
@@ -399,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;
|
||||||
/**
|
/**
|
||||||
@@ -430,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. */
|
||||||
@@ -456,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.
|
||||||
@@ -511,6 +623,28 @@ export interface CliCapabilities {
|
|||||||
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
||||||
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
||||||
maxFrameBytes?: number;
|
maxFrameBytes?: number;
|
||||||
|
/**
|
||||||
|
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
|
||||||
|
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
|
||||||
|
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
|
||||||
|
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
|
||||||
|
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
|
||||||
|
*/
|
||||||
|
newline?: NewlineSequence;
|
||||||
|
/**
|
||||||
|
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
|
||||||
|
* `path` is relative to the home directory. `format` names the file dialect the sync
|
||||||
|
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
||||||
|
* skipped by sync rather than guessed at.
|
||||||
|
*
|
||||||
|
* `relocation` names the env var the CLI itself reads to move that file (codex's
|
||||||
|
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
|
||||||
|
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
|
||||||
|
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
|
||||||
|
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
|
||||||
|
* only follows `$HOME`.
|
||||||
|
*/
|
||||||
|
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
|
||||||
/**
|
/**
|
||||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* @module config/dependency-registry
|
* @module config/dependency-registry
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { enabledClis } from './cli-registry/registry.js';
|
import { enabledClis } from './cli-registry/registry.js';
|
||||||
import { compileVersionRegex } from './cli-registry/patterns.js';
|
import { compileVersionRegex } from './cli-registry/patterns.js';
|
||||||
|
|
||||||
@@ -30,6 +32,24 @@ export interface PathResolver {
|
|||||||
* there and a false "installed" contradicts the run mode's own resolver.
|
* there and a false "installed" contradicts the run mode's own resolver.
|
||||||
*/
|
*/
|
||||||
requireVersionMatch?: boolean;
|
requireVersionMatch?: boolean;
|
||||||
|
/**
|
||||||
|
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
|
||||||
|
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
|
||||||
|
* prefix is invisible to `which` while the run mode, which falls back to the registry's
|
||||||
|
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
|
||||||
|
*/
|
||||||
|
searchDirs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
|
||||||
|
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
|
||||||
|
* would pull in the whole resolver chain; keep the two in step.
|
||||||
|
*/
|
||||||
|
function expandSearchDir(dir: string): string {
|
||||||
|
if (dir === '~') return homedir();
|
||||||
|
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
||||||
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
||||||
@@ -131,6 +151,7 @@ function cliDependencyEntries(): ToolDependency[] {
|
|||||||
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
|
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
|
||||||
// program, so a version mismatch means MISSING rather than unknown-version.
|
// program, so a version mismatch means MISSING rather than unknown-version.
|
||||||
requireVersionMatch: version?.requireVersionMatch,
|
requireVersionMatch: version?.requireVersionMatch,
|
||||||
|
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
|
||||||
|
*
|
||||||
|
* A linked case can live on a network mount, and a hard mount that went away makes
|
||||||
|
* `stat()` wait until the mount comes back. The probe gives up on such a path after
|
||||||
|
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
|
||||||
|
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
|
||||||
|
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
|
||||||
|
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
|
||||||
|
*
|
||||||
|
* Both are env-overridable, in the same style as the other config modules. A slow
|
||||||
|
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
|
||||||
|
* a longer timeout. The stall limits follow `UV_THREADPOOL_SIZE` on their own, so a
|
||||||
|
* server started with a larger pool gets a higher ceiling without further setup.
|
||||||
|
*
|
||||||
|
* @module config/path-probe
|
||||||
|
*/
|
||||||
|
|
||||||
|
function envInt(name: string, fallback: number, min: number, max: number): number {
|
||||||
|
const raw = parseInt(process.env[name] || '', 10);
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a caller waits for one path probe before the answer is "unknown". */
|
||||||
|
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard ceiling on timed-out probes left pending, for every caller, `pastCap` ones
|
||||||
|
* included: the threadpool size minus one, so a dead mount can never take the last
|
||||||
|
* worker. libuv sizes the pool from `UV_THREADPOOL_SIZE` (4 when unset). A pool of
|
||||||
|
* one cannot keep a worker free at all, so the ceiling never drops below one.
|
||||||
|
*/
|
||||||
|
export const PATH_PROBE_STALL_CEILING = Math.max(1, (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timed-out probes allowed to stay pending before new BULK probes are refused
|
||||||
|
* (answered "unknown" without a stat). This is a backstop, not the main defence: a
|
||||||
|
* stalled path on a network or FUSE mount already takes the rest of that mount out
|
||||||
|
* of probing (a stall anywhere else takes out only the stalled path), so the cap
|
||||||
|
* only engages once that many UNRELATED places have stopped answering. It defaults
|
||||||
|
* to one below {@link PATH_PROBE_STALL_CEILING} (2 with the default pool), leaving a
|
||||||
|
* slot a `pastCap` probe may still use, and is never allowed above the ceiling.
|
||||||
|
*/
|
||||||
|
export const MAX_STALLED_PATH_PROBES = Math.min(
|
||||||
|
PATH_PROBE_STALL_CEILING,
|
||||||
|
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
|
||||||
|
);
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,801 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
|
||||||
|
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
|
||||||
|
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
|
||||||
|
* visible without leaving Codeman.
|
||||||
|
*
|
||||||
|
* Split so the parts that matter test without a repo:
|
||||||
|
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
|
||||||
|
* `parseCommitLog`
|
||||||
|
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
|
||||||
|
* single-flight cache so several tabs polling one repo cost one set of git processes
|
||||||
|
*
|
||||||
|
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
|
||||||
|
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
|
||||||
|
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||||
|
* to the outer one, and is not scanned;
|
||||||
|
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||||
|
* DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules`
|
||||||
|
* 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
|
||||||
|
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
||||||
|
*
|
||||||
|
* Rules the code keeps and the tests pin:
|
||||||
|
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
|
||||||
|
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
|
||||||
|
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
|
||||||
|
* refreshing the index, so polling cannot contend with the agent's own git commands.
|
||||||
|
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
|
||||||
|
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
|
||||||
|
* revision range.
|
||||||
|
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
|
||||||
|
* - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
|
||||||
|
* during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
|
||||||
|
* runs as this same OS user, so polling adds no privilege there. What is turned off: the
|
||||||
|
* filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
|
||||||
|
* program (`log.showSignature`). A repository a container can write to is NOT inspected: a
|
||||||
|
* Docker session answers `unsupported`, and any repository whose root is, or is inside, a Docker
|
||||||
|
* case workspace is dropped from the walk-up, the scan below a folder, and the diff route, because
|
||||||
|
* the container could have planted that config and git here would run it on the host.
|
||||||
|
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
|
||||||
|
* through `redactGitCredentials`.
|
||||||
|
*
|
||||||
|
* @module git-workspace-status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { basename, join, relative, sep } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||||
|
/** Max file rows returned. The counts stay exact. */
|
||||||
|
export const MAX_FILES = 300;
|
||||||
|
/** Max unpushed commits listed. The count stays exact. */
|
||||||
|
export const MAX_COMMITS = 50;
|
||||||
|
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
|
||||||
|
const CACHE_TTL_MS = 4000;
|
||||||
|
const CACHE_MAX_ENTRIES = 64;
|
||||||
|
|
||||||
|
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
|
||||||
|
|
||||||
|
export interface GitFileEntry {
|
||||||
|
/** Path relative to the repository root, as git reports it. */
|
||||||
|
path: string;
|
||||||
|
/** Rename/copy source, when the entry is one. */
|
||||||
|
origPath?: string;
|
||||||
|
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
|
||||||
|
index: string;
|
||||||
|
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
|
||||||
|
worktree: string;
|
||||||
|
kind: GitFileKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCommitEntry {
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
/** Seconds since the epoch. */
|
||||||
|
time: number;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorkspaceStatus {
|
||||||
|
/**
|
||||||
|
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
|
||||||
|
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
|
||||||
|
*/
|
||||||
|
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||||
|
reason?: 'remote' | 'docker';
|
||||||
|
error?: string;
|
||||||
|
repoRoot?: string;
|
||||||
|
/** Null when HEAD is detached. */
|
||||||
|
branch: string | null;
|
||||||
|
detached: boolean;
|
||||||
|
upstream: string | null;
|
||||||
|
/**
|
||||||
|
* The configured upstream does not exist on the remote (deleted and pruned, or never pushed, as after
|
||||||
|
* cloning an empty repository and committing): nothing is tracked.
|
||||||
|
*/
|
||||||
|
upstreamGone: boolean;
|
||||||
|
ahead: number;
|
||||||
|
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
|
||||||
|
behind: number;
|
||||||
|
/** Whether the repository has any remote at all. */
|
||||||
|
hasRemote: boolean;
|
||||||
|
counts: {
|
||||||
|
staged: number;
|
||||||
|
unstaged: number;
|
||||||
|
untracked: number;
|
||||||
|
conflicted: number;
|
||||||
|
/** Distinct paths that are not committed. */
|
||||||
|
uncommitted: number;
|
||||||
|
stashes: number;
|
||||||
|
};
|
||||||
|
files: GitFileEntry[];
|
||||||
|
filesTruncated: boolean;
|
||||||
|
/** Commits on this branch that no remote has: exact. */
|
||||||
|
unpushedCount: number;
|
||||||
|
unpushed: GitCommitEntry[];
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
|
||||||
|
branch: null,
|
||||||
|
detached: false,
|
||||||
|
upstream: null,
|
||||||
|
upstreamGone: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
hasRemote: false,
|
||||||
|
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
|
||||||
|
files: [],
|
||||||
|
filesTruncated: false,
|
||||||
|
unpushedCount: 0,
|
||||||
|
unpushed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyStatus = (
|
||||||
|
state: GitWorkspaceStatus['state'],
|
||||||
|
extra: Partial<GitWorkspaceStatus> = {}
|
||||||
|
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ParsedStatus {
|
||||||
|
branch: string | null;
|
||||||
|
detached: boolean;
|
||||||
|
upstream: string | null;
|
||||||
|
/** `# branch.upstream` was printed but `# branch.ab` was not: no such remote branch (deleted and pruned, or never pushed). */
|
||||||
|
upstreamGone: boolean;
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
files: GitFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
|
||||||
|
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
|
||||||
|
* one more NUL-terminated token holding the original path.
|
||||||
|
*/
|
||||||
|
export function parsePorcelainV2(text: string): ParsedStatus {
|
||||||
|
const out: ParsedStatus = {
|
||||||
|
branch: null,
|
||||||
|
detached: false,
|
||||||
|
upstream: null,
|
||||||
|
upstreamGone: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
let sawAb = false;
|
||||||
|
const tokens = text.split('\0');
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
const t = tokens[i];
|
||||||
|
if (!t) continue;
|
||||||
|
if (t.startsWith('# ')) {
|
||||||
|
const [key, ...rest] = t.slice(2).split(' ');
|
||||||
|
const value = rest.join(' ');
|
||||||
|
if (key === 'branch.head') {
|
||||||
|
out.detached = value === '(detached)';
|
||||||
|
out.branch = out.detached ? null : value;
|
||||||
|
} else if (key === 'branch.upstream') {
|
||||||
|
out.upstream = value;
|
||||||
|
} else if (key === 'branch.ab') {
|
||||||
|
sawAb = true;
|
||||||
|
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
||||||
|
if (m) {
|
||||||
|
out.ahead = Number(m[1]);
|
||||||
|
out.behind = Number(m[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = t[0];
|
||||||
|
if (type === '1') {
|
||||||
|
// 1 XY sub mH mI mW hH hI path
|
||||||
|
const f = t.split(' ');
|
||||||
|
const xy = f[1] ?? '..';
|
||||||
|
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
|
||||||
|
} else if (type === '2') {
|
||||||
|
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
|
||||||
|
const f = t.split(' ');
|
||||||
|
const xy = f[1] ?? '..';
|
||||||
|
const path = f.slice(9).join(' ');
|
||||||
|
const origPath = tokens[++i] ?? '';
|
||||||
|
out.files.push(...entriesFor(xy, path, origPath));
|
||||||
|
} else if (type === 'u') {
|
||||||
|
// u XY sub m1 m2 m3 mW h1 h2 h3 path
|
||||||
|
const f = t.split(' ');
|
||||||
|
out.files.push({
|
||||||
|
path: f.slice(10).join(' '),
|
||||||
|
index: f[1]?.[0] ?? 'U',
|
||||||
|
worktree: f[1]?.[1] ?? 'U',
|
||||||
|
kind: 'conflicted',
|
||||||
|
});
|
||||||
|
} else if (type === '?') {
|
||||||
|
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
|
||||||
|
}
|
||||||
|
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
|
||||||
|
}
|
||||||
|
out.upstreamGone = out.upstream !== null && !sawAb;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
|
||||||
|
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
|
||||||
|
const index = xy[0] ?? '.';
|
||||||
|
const worktree = xy[1] ?? '.';
|
||||||
|
const rows: GitFileEntry[] = [];
|
||||||
|
const base = origPath ? { path, origPath } : { path };
|
||||||
|
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
|
||||||
|
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
|
||||||
|
export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||||
|
const out: GitCommitEntry[] = [];
|
||||||
|
for (const record of text.split('\x1e')) {
|
||||||
|
const r = record.replace(/^\n+/, '');
|
||||||
|
if (!r) continue;
|
||||||
|
const [hash, author, time, ...subject] = r.split('\x1f');
|
||||||
|
if (!hash) continue;
|
||||||
|
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IO
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 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[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||||
|
|
||||||
|
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||||
|
// consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
|
||||||
|
// a configured gpg.program to verify signatures.
|
||||||
|
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
||||||
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
|
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
||||||
|
const e = err as { code?: unknown; stderr?: unknown; message?: string };
|
||||||
|
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
|
||||||
|
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
|
||||||
|
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
|
||||||
|
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
|
||||||
|
return { notARepo: false, message: 'git timed out' };
|
||||||
|
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
|
||||||
|
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(cwd: string, git: GitRunner, timeoutMs?: number): Promise<GitWorkspaceStatus> {
|
||||||
|
let statusText: string;
|
||||||
|
try {
|
||||||
|
statusText = await git(
|
||||||
|
cwd,
|
||||||
|
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const f = describeFailure(err);
|
||||||
|
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||||
|
}
|
||||||
|
const parsed = parsePorcelainV2(statusText);
|
||||||
|
|
||||||
|
const safe = async (args: string[]): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await git(cwd, args, { timeoutMs });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
|
||||||
|
// resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
|
||||||
|
const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
|
||||||
|
// With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
|
||||||
|
// upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
|
||||||
|
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
|
||||||
|
const [root, remotes, stash, countText, logText] = await Promise.all([
|
||||||
|
safe(['rev-parse', '--show-toplevel']),
|
||||||
|
safe(['remote']),
|
||||||
|
safe(['stash', 'list', '--format=%gd']),
|
||||||
|
safe(['rev-list', '--count', ...range]),
|
||||||
|
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasRemote = remotes.trim().length > 0;
|
||||||
|
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
|
||||||
|
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
|
||||||
|
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
|
||||||
|
|
||||||
|
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
|
||||||
|
const distinct = new Set<string>();
|
||||||
|
for (const f of parsed.files) {
|
||||||
|
counts[f.kind]++;
|
||||||
|
distinct.add(f.path);
|
||||||
|
}
|
||||||
|
counts.uncommitted = distinct.size;
|
||||||
|
counts.stashes = stash.split('\n').filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: 'ok',
|
||||||
|
repoRoot: root.trim() || undefined,
|
||||||
|
branch: parsed.branch,
|
||||||
|
detached: parsed.detached,
|
||||||
|
upstream: parsed.upstream,
|
||||||
|
upstreamGone: parsed.upstreamGone,
|
||||||
|
ahead: parsed.ahead,
|
||||||
|
behind: parsed.behind,
|
||||||
|
hasRemote,
|
||||||
|
counts,
|
||||||
|
files: parsed.files.slice(0, MAX_FILES),
|
||||||
|
filesTruncated: parsed.files.length > MAX_FILES,
|
||||||
|
unpushedCount,
|
||||||
|
unpushed,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
at: number;
|
||||||
|
value?: T;
|
||||||
|
inflight?: Promise<T>;
|
||||||
|
}
|
||||||
|
const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
|
||||||
|
|
||||||
|
/** For tests. */
|
||||||
|
export function clearGitStatusCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
toplevelCache.clear();
|
||||||
|
discoveryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
|
||||||
|
* flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
|
||||||
|
* Refresh and expects the truth) but still joins a computation that is already running, which is as
|
||||||
|
* current as a new one would be.
|
||||||
|
*/
|
||||||
|
async function singleFlight<T>(
|
||||||
|
map: Map<string, CacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
opts: { now: () => number; fresh?: boolean },
|
||||||
|
compute: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const hit = map.get(key);
|
||||||
|
if (hit?.inflight) return hit.inflight;
|
||||||
|
if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||||
|
|
||||||
|
const inflight = compute();
|
||||||
|
map.set(key, { at: opts.now(), inflight });
|
||||||
|
try {
|
||||||
|
const value = await inflight;
|
||||||
|
map.set(key, { at: opts.now(), value });
|
||||||
|
if (map.size > CACHE_MAX_ENTRIES) {
|
||||||
|
for (const [k, v] of map) {
|
||||||
|
if (map.size <= CACHE_MAX_ENTRIES) break;
|
||||||
|
if (k !== key && !v.inflight) map.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (err) {
|
||||||
|
map.delete(key);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
|
||||||
|
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
|
||||||
|
* `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
|
||||||
|
*/
|
||||||
|
export async function getGitWorkspaceStatus(
|
||||||
|
cwd: string,
|
||||||
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
|
||||||
|
): Promise<GitWorkspaceStatus> {
|
||||||
|
const git = opts.git ?? runGit;
|
||||||
|
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 };
|
||||||
|
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. */
|
||||||
|
function enclosingRepoRoot(
|
||||||
|
cwd: string,
|
||||||
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
|
||||||
|
): Promise<RepoToplevel> {
|
||||||
|
const git = opts.git ?? runGit;
|
||||||
|
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||||
|
try {
|
||||||
|
const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
|
||||||
|
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||||
|
} catch (err) {
|
||||||
|
const f = describeFailure(err);
|
||||||
|
return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Which repositories: the overview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
|
||||||
|
const DISCOVERY_MAX_DEPTH = 2;
|
||||||
|
/** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
|
||||||
|
const DISCOVERY_MAX_ENTRIES = 300;
|
||||||
|
/** Repositories reported for one workspace. */
|
||||||
|
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. */
|
||||||
|
const DISCOVERY_TTL_MS = 30_000;
|
||||||
|
/** Folders that are never worth descending into when looking for projects. */
|
||||||
|
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
|
||||||
|
/** Status calls in flight at once for one overview: each is several git processes. */
|
||||||
|
const STATUS_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
export interface GitRepoEntry {
|
||||||
|
/** Folder name of the repository (its root's basename). */
|
||||||
|
name: string;
|
||||||
|
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
|
||||||
|
path: string;
|
||||||
|
status: GitWorkspaceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorkspaceOverview {
|
||||||
|
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
|
||||||
|
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||||
|
reason?: 'remote' | 'docker';
|
||||||
|
error?: string;
|
||||||
|
repos: GitRepoEntry[];
|
||||||
|
/** More than `repoLimit` repositories were found; only the first are reported. */
|
||||||
|
reposTruncated: boolean;
|
||||||
|
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
|
||||||
|
repoLimit?: number;
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyOverview = (
|
||||||
|
state: GitWorkspaceOverview['state'],
|
||||||
|
extra: Partial<GitWorkspaceOverview> = {}
|
||||||
|
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
|
||||||
|
|
||||||
|
const realOr = async (p: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await fs.realpath(p);
|
||||||
|
} catch {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
|
||||||
|
const realAll = (dirs: string[]): Promise<string[]> => Promise.all(dirs.map(realOr));
|
||||||
|
|
||||||
|
const isWithin = (child: string, root: string): boolean => child === root || child.startsWith(root + sep);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
|
||||||
|
* workspaces: a container can write there, so git must not run on its behalf on the host.
|
||||||
|
*/
|
||||||
|
export async function isInsideAny(path: string, realRoots: string[]): Promise<boolean> {
|
||||||
|
if (!realRoots.length) return false;
|
||||||
|
const real = await realOr(path);
|
||||||
|
return realRoots.some((r) => isWithin(real, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
|
||||||
|
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
|
||||||
|
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
|
||||||
|
*/
|
||||||
|
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
|
||||||
|
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
|
||||||
|
if (root === here) return false;
|
||||||
|
return root === sep || h === root || h.startsWith(root + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasDotGit(dir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
|
||||||
|
const DISCOVERY_MAX_SCAN = 5000;
|
||||||
|
|
||||||
|
/** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
|
||||||
|
async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] | null> {
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await fs.opendir(dir);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out: import('node:fs').Dirent[] = [];
|
||||||
|
try {
|
||||||
|
for await (const e of handle) {
|
||||||
|
out.push(e);
|
||||||
|
if (out.length >= DISCOVERY_MAX_SCAN) break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* a folder that fails mid-read: use what was read */
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||||
|
export async function discoverChildRepos(
|
||||||
|
cwd: string,
|
||||||
|
excludeRealRoots: string[] = [],
|
||||||
|
maxRepos: number = MAX_REPOS
|
||||||
|
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||||
|
const found: string[] = [];
|
||||||
|
let level = [cwd];
|
||||||
|
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const dir of level) {
|
||||||
|
const entries = await readDirBounded(dir);
|
||||||
|
if (!entries) continue;
|
||||||
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||||
|
entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
|
||||||
|
for (const e of entries) {
|
||||||
|
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
|
||||||
|
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
|
||||||
|
const child = join(dir, e.name);
|
||||||
|
// A Docker case workspace (or anything inside one) is never inspected, nor descended into.
|
||||||
|
if (await isInsideAny(child, excludeRealRoots)) continue;
|
||||||
|
if (await hasDotGit(child)) found.push(child);
|
||||||
|
else next.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||||
|
|
||||||
|
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
|
||||||
|
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const out: R[] = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++;
|
||||||
|
out[i] = await fn(items[i]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitOverviewOptions {
|
||||||
|
git?: GitRunner;
|
||||||
|
now?: () => number;
|
||||||
|
fresh?: boolean;
|
||||||
|
home?: string;
|
||||||
|
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||||
|
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 =
|
||||||
|
| { kind: 'docker' }
|
||||||
|
| { kind: 'error'; error: string }
|
||||||
|
| { kind: 'enclosing'; root: string }
|
||||||
|
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||||
|
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
|
||||||
|
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
|
||||||
|
*/
|
||||||
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||||
|
const now = opts.now ?? Date.now;
|
||||||
|
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||||
|
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||||
|
// 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.
|
||||||
|
if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
|
||||||
|
// 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
|
||||||
|
// repositories below.
|
||||||
|
const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
|
||||||
|
if (top.state === 'error') return { kind: 'error', error: top.error };
|
||||||
|
if (top.state === 'ok') {
|
||||||
|
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
||||||
|
if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
|
||||||
|
return { kind: 'enclosing', root: top.root };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not inside a repository of this workspace: look below for projects.
|
||||||
|
// 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 };
|
||||||
|
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||||
|
else {
|
||||||
|
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
|
||||||
|
discoveryCache.set(discoveryKey, { at: now(), value: found });
|
||||||
|
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.
|
||||||
|
const dirs: string[] = [];
|
||||||
|
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
||||||
|
return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything git knows about the session's workspace: the enclosing repository when there is one,
|
||||||
|
* otherwise each repository found below the working directory. See the module header for the rules.
|
||||||
|
*/
|
||||||
|
export async function getGitWorkspaceOverview(
|
||||||
|
cwd: string,
|
||||||
|
opts: GitOverviewOptions = {}
|
||||||
|
): Promise<GitWorkspaceOverview> {
|
||||||
|
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||||
|
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||||
|
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||||
|
if (where.kind === 'enclosing') {
|
||||||
|
const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
|
||||||
|
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||||
|
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||||
|
const root = primary.repoRoot ?? where.root;
|
||||||
|
return {
|
||||||
|
state: 'ok',
|
||||||
|
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
|
||||||
|
reposTruncated: false,
|
||||||
|
checkedAt: primary.checkedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
|
||||||
|
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
|
||||||
|
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
|
||||||
|
);
|
||||||
|
const repos: GitRepoEntry[] = [];
|
||||||
|
where.dirs.forEach((dir, i) => {
|
||||||
|
const status = statuses[i];
|
||||||
|
// 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');
|
||||||
|
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
|
||||||
|
* and caches, and the Docker roots as they are now), else null. The diff route checks a requested
|
||||||
|
* repository with this rather than recomputing every repository's status.
|
||||||
|
*/
|
||||||
|
export async function findWorkspaceRepo(
|
||||||
|
cwd: string,
|
||||||
|
repo: string,
|
||||||
|
opts: GitOverviewOptions = {}
|
||||||
|
): Promise<string | null> {
|
||||||
|
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||||
|
const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
|
||||||
|
// git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
|
||||||
|
for (const root of roots) if (root === repo || (await realOr(root)) === repo) return repo;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-file diff ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
|
||||||
|
export const MAX_DIFF_BYTES = 400 * 1024;
|
||||||
|
|
||||||
|
export interface GitFileDiff {
|
||||||
|
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
|
||||||
|
diff: string;
|
||||||
|
truncated: boolean;
|
||||||
|
binary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
|
||||||
|
export function isSafeRepoRelativePath(p: string): boolean {
|
||||||
|
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/')) return false;
|
||||||
|
return !p.split('/').includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
|
||||||
|
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
|
||||||
|
* `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
|
||||||
|
* diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
|
||||||
|
* `git diff`, which is why a container-writable repository never reaches this function.
|
||||||
|
*/
|
||||||
|
export async function getGitFileDiff(
|
||||||
|
repoRoot: string,
|
||||||
|
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||||
|
opts: { git?: GitRunner; timeoutMs?: number } = {}
|
||||||
|
): Promise<GitFileDiff> {
|
||||||
|
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||||
|
throw new Error('Invalid path');
|
||||||
|
}
|
||||||
|
const git = opts.git ?? runGit;
|
||||||
|
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
|
||||||
|
let args: string[];
|
||||||
|
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
|
||||||
|
else {
|
||||||
|
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
|
||||||
|
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
|
||||||
|
}
|
||||||
|
let out: string;
|
||||||
|
let cutShort = false;
|
||||||
|
try {
|
||||||
|
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { code?: unknown; stdout?: unknown };
|
||||||
|
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||||
|
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
|
||||||
|
// A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
|
||||||
|
// any oversized diff.
|
||||||
|
else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
|
||||||
|
out = e.stdout;
|
||||||
|
cutShort = true;
|
||||||
|
} else throw err;
|
||||||
|
}
|
||||||
|
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
|
||||||
|
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: cutShort, binary };
|
||||||
|
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
|
||||||
|
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
@@ -40,6 +39,52 @@ import type { HookEventType } from './types.js';
|
|||||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||||
import { dataPath } from './config/instance.js';
|
import { dataPath } from './config/instance.js';
|
||||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||||
|
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||||
|
* which gives up after a timeout and answers "unknown", this waits for the real
|
||||||
|
* answer: only ENOENT reads as absent, anything else throws, so a
|
||||||
|
* stalled or unreadable workspace can never be mistaken for an empty one and
|
||||||
|
* have its settings recreated over the top. It is async, so a dead mount ties
|
||||||
|
* up a threadpool worker rather than the event loop.
|
||||||
|
*/
|
||||||
|
async function pathExistsForWrite(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
|
||||||
|
* stalled probe (the bulk cap refused it, or the stat failed with something other
|
||||||
|
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
|
||||||
|
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
|
||||||
|
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
|
||||||
|
* on a dead mount those never settle, and each would hold a threadpool worker the
|
||||||
|
* probe's ceiling does not count.
|
||||||
|
*/
|
||||||
|
async function probeBeforeTouching(path: string) {
|
||||||
|
const state = await probePath(path);
|
||||||
|
if (state !== 'unknown' || isNearStalledPath(path)) return state;
|
||||||
|
return probePath(path, { pastCap: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
|
||||||
|
* it did not answer (a mount that is not responding, a refused probe, an unreadable
|
||||||
|
* path). See `probeBeforeTouching` for why "unknown" is a skip.
|
||||||
|
*/
|
||||||
|
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
|
||||||
|
const state = await probeBeforeTouching(path);
|
||||||
|
if (state === 'absent') return 'absent';
|
||||||
|
if (state === 'unknown') return 'unreachable';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||||
@@ -558,7 +603,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
|||||||
if (keysToRemove.length === 0) return;
|
if (keysToRemove.length === 0) return;
|
||||||
|
|
||||||
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
|
||||||
if (!existsSync(settingsPath)) return;
|
if (!(await pathExistsForWrite(settingsPath))) return;
|
||||||
|
|
||||||
let existing: Record<string, unknown>;
|
let existing: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -590,7 +635,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
|||||||
*/
|
*/
|
||||||
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +666,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
|||||||
*/
|
*/
|
||||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +695,7 @@ export async function updateCaseModel(casePath: string, model: string | null): P
|
|||||||
*/
|
*/
|
||||||
export async function writeHooksConfig(casePath: string): Promise<void> {
|
export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +743,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +783,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
|||||||
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
||||||
*/
|
*/
|
||||||
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
||||||
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
|
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
|
||||||
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
|
||||||
let existing: Record<string, unknown>;
|
let existing: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -820,7 +865,17 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
|||||||
*/
|
*/
|
||||||
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(workspace)) return;
|
// "absent" stays absent: the install below would mkdir -p a deleted repo back
|
||||||
|
// into being. "unknown" is skipped too, never asked again with an unbounded
|
||||||
|
// lstat (see probeBeforeTouching).
|
||||||
|
const state = await probeBeforeTouching(workspace);
|
||||||
|
if (state === 'absent') return;
|
||||||
|
if (state === 'unknown') {
|
||||||
|
console.warn(
|
||||||
|
`[hooks] ${workspace} is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
|
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
|
||||||
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -883,7 +938,7 @@ export function generateStatusLineCommand(): string {
|
|||||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
||||||
let existing: Record<string, unknown> = {};
|
let existing: Record<string, unknown> = {};
|
||||||
if (existsSync(settingsPath)) {
|
if (await pathExistsForWrite(settingsPath)) {
|
||||||
try {
|
try {
|
||||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -898,7 +953,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
|||||||
const desired = generateStatusLineCommand();
|
const desired = generateStatusLineCommand();
|
||||||
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||||
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
||||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
if (!(await pathExistsForWrite(claudeDir))) await mkdir(claudeDir, { recursive: true });
|
||||||
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
||||||
} else {
|
} else {
|
||||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||||
@@ -957,7 +1012,7 @@ function statusLineExporterScriptContent(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
|
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
|
||||||
if (!existsSync(settingsPath)) return undefined;
|
if (await absentOrUnreachable(settingsPath)) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||||
const current = parsed.statusLine as { command?: unknown } | undefined;
|
const current = parsed.statusLine as { command?: unknown } | undefined;
|
||||||
@@ -1031,7 +1086,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
|||||||
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
||||||
// windows in which Claude Code could run an empty or non-executable file.
|
// windows in which Claude Code could run an empty or non-executable file.
|
||||||
// rename() swaps the complete, already-executable file in atomically.
|
// rename() swaps the complete, already-executable file in atomically.
|
||||||
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
|
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
|
||||||
|
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
|
||||||
|
// name let the first rename consume the others' temp file, and their ENOENT
|
||||||
|
// dropped those sessions from tmux to the direct-PTY fallback.
|
||||||
|
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||||
await writeFile(tmpPath, desired);
|
await writeFile(tmpPath, desired);
|
||||||
await chmod(tmpPath, 0o755);
|
await chmod(tmpPath, 0o755);
|
||||||
await rename(tmpPath, scriptPath);
|
await rename(tmpPath, scriptPath);
|
||||||
@@ -1099,9 +1158,21 @@ export async function resolveStatusLineCliCommand(
|
|||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||||
let userHasOwnStatusLine = false;
|
let userHasOwnStatusLine = false;
|
||||||
if (existsSync(settingsPath)) {
|
const skip = await absentOrUnreachable(settingsPath);
|
||||||
|
// Unreachable: whether the user configured their own statusLine there cannot be
|
||||||
|
// told, and this must never override a real one, so inject nothing.
|
||||||
|
if (skip === 'unreachable') return undefined;
|
||||||
|
if (!skip) {
|
||||||
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
raw = await readFile(settingsPath, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
|
||||||
|
raw = '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = raw ? JSON.parse(raw) : {};
|
||||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||||
if (current && typeof current.command === 'string') {
|
if (current && typeof current.command === 'string') {
|
||||||
if (current.command.includes(STATUSLINE_MARKER)) {
|
if (current.command.includes(STATUSLINE_MARKER)) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||