mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1614e89fc | ||
|
|
0aa16cd4d3 | ||
|
|
4ed86aa0cd | ||
|
|
a15b81db77 | ||
|
|
341c7ccc59 | ||
|
|
c1719e04e5 | ||
|
|
d33f3803a1 | ||
|
|
477e73039c | ||
|
|
b374032699 | ||
|
|
b6efdfccf4 | ||
|
|
b191f3c2c6 | ||
|
|
9fd856a918 | ||
|
|
be449e6e9e | ||
|
|
04de943b7f | ||
|
|
9e7c537e14 | ||
|
|
55bff4a4bf | ||
|
|
fa02bd4503 | ||
|
|
5bde897752 | ||
|
|
6c55ce3f8d | ||
|
|
a30524060a | ||
|
|
00fb3b0908 | ||
|
|
5aa59c70cc | ||
|
|
ffccde4f7d | ||
|
|
bec3da3d31 | ||
|
|
6e89eb9ec1 | ||
|
|
94aa53c65b | ||
|
|
e88b971bb7 | ||
|
|
8406c497e2 | ||
|
|
40b4aba043 | ||
|
|
4b44988bfc | ||
|
|
316d0a4c82 | ||
|
|
1184720648 | ||
|
|
b067aad9b6 | ||
|
|
19a3d7c773 | ||
|
|
085f4acb60 | ||
|
|
d26f26fe34 | ||
|
|
091df2b6d8 | ||
|
|
5f775b1ab1 | ||
|
|
da51193264 | ||
|
|
fa18eeef35 | ||
|
|
a9f26bd03a | ||
|
|
8dc8b164a7 | ||
|
|
2524759655 | ||
|
|
1f164bc8d2 | ||
|
|
0a1439b1e9 | ||
|
|
66abe6c70a | ||
|
|
fa1700da5b | ||
|
|
aed1e59ee3 | ||
|
|
7f6d18b398 | ||
|
|
52571c7fd4 | ||
|
|
cb95a8562c | ||
|
|
3cb7e30636 | ||
|
|
9dc4620f03 | ||
|
|
9d27cc0bab | ||
|
|
84132d3025 | ||
|
|
ebfcac6ad1 | ||
|
|
2e69e28e71 | ||
|
|
1a32e63765 |
@@ -91,6 +91,14 @@ jobs:
|
||||
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
|
||||
run: npm run test:ci
|
||||
|
||||
- name: Run xterm-zerolag-input package tests
|
||||
# Layers 1-3 of the predictive-echo suites (unit laws, fixture replay,
|
||||
# seeded fuzz): deterministic, no browser, no live server. Depends on
|
||||
# the ROOT `npm ci` above — workspaces hoist the package's vitest into
|
||||
# the root node_modules; do not add a separate install here.
|
||||
run: npx vitest run
|
||||
working-directory: packages/xterm-zerolag-input
|
||||
|
||||
# Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI —
|
||||
# it needs a live server + chromium + environment-specific PNG baselines.
|
||||
# Run it locally/manually. All other tests run via the `test` job above.
|
||||
|
||||
+305
@@ -1,5 +1,310 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 55bff4a: Zero-lag predictive echo for Codex sessions (mosh-style write-through prediction).
|
||||
|
||||
Codex's per-keystroke composer forced 1.12.2 to disable the local-echo overlay (issues #218/#219/#220/#222), leaving Codex typing at full round-trip latency on remote links. This release adds a second echo mode instead of re-enabling the first: every keystroke still goes to the PTY exactly as before (byte-identical wire behavior, pinned by vm-level and end-to-end trace-equality tests), while the new `PredictiveEchoAddon` in `xterm-zerolag-input` 0.2.0 paints the predicted glyph at the predicted cell. When the real echo lands, the prediction is confirmed and its span removed (an invisible swap); mispredictions self-heal via a two-pass mismatch cascade and a TTL.
|
||||
- Reconciliation reads the parsed terminal buffer, never the raw stream: full-line redraws, ECH gap painting and tmux's in-place deltas all converge to the same cells. Confirmation requires the cell match PLUS a cursor advance, so placeholder glyphs and identical repaints never false-confirm; blank cells are neutral (codex clears its placeholder on the first echo).
|
||||
- Predictions paint only while the cursor sits on the measured Codex composer row (`/^› /`, codex-cli 0.147): trust/approval modals and wrapped continuation rows get no ghosts, deliberately falling back to real echo.
|
||||
- Ships as a SEPARATE `vendor/xterm-predictive-echo.js` bundle: the existing zerolag bundle is byte-identical (sha256-verified), and a missing or broken bundle degrades Codex to exact 1.12.2 behavior. The per-device `localEchoEnabled` toggle is the kill switch.
|
||||
- Claude/Gemini/OpenCode/Antigravity keep buffer mode untouched; shell stays off.
|
||||
- A post-build adversarial review added the anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, IME text commits) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run.
|
||||
- Tests: 55 new package tests including replay suites driven by fixtures recorded from a real codex TUI through the production tmux+strip pipeline (`scripts/dev/record-codex-frames.mjs`) and a 500-iteration seeded fuzz; new vm policy/wire-neutrality suites; a 10-scenario Playwright E2E against real codex covering the #218/#219/#220/#222 retests, byte-identity, and a simulated 300ms-RTT run. The package test suite now runs in CI.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Agent-skill hardening, plus a fix for the mobile browser suite.
|
||||
|
||||
## The Codeman agent skill
|
||||
|
||||
Twelve issues found by auditing the skill against a live instance, and fixing them meant measuring things rather than reasoning about them.
|
||||
|
||||
**Readiness now works in every permission mode.** The ladder matched `bypass`, which is the status bar of only ONE mode. Measured one pane per mode against claude-cli 2.1.226:
|
||||
|
||||
| how Codeman spawned it | statusline | `shift+tab` | `bypass` |
|
||||
| ------------------------------------------ | ----------------------- | ----------- | -------- |
|
||||
| `--dangerously-skip-permissions` (default) | `bypass permissions on` | yes | yes |
|
||||
| `--permission-mode auto` | `auto mode on` | yes | no |
|
||||
| `--allowedTools …` | `don't ask on` | yes | no |
|
||||
| neither (`normal`) | `don't ask on` | yes | no |
|
||||
| `--permission-mode plan` | `plan mode on` | yes | no |
|
||||
|
||||
Every mode ends `(shift+tab to cycle)`, and the `claudeMode` setting is not exposed on `GET /api/v1/sessions/:id`, so there was nothing to branch on. The ladder matches `shift+tab` now: universal, and space-free, which is what makes it survive the TUI stream. A non-default worker used to be reported broken after burning the full budget. ⚠️ The `+` means it only works through `--data-urlencode`; a hand-built query silently searches for `shift tab`.
|
||||
|
||||
**`.status` is documented as unreliable in both directions.** Measured on a live worker reading `idle` while mid-turn and actively producing output, with `lastActivityAt` equal to the moment of the call. A worker that dies inside its pane also reads `idle`. Synchronize on `stop` or an output marker; to judge from outside, sample `terminal?tail=` twice and compare.
|
||||
|
||||
**The self-delete guard is fail-closed.** Documented in 1.14.2; the reference files and every recipe now route through it consistently.
|
||||
|
||||
**Reads work on macOS.** The ANSI-strip pipelines used `sed 's/\x1b…'`, and BSD sed has no `\xHH` escape, so on macOS they silently stripped nothing and handed the agent raw ANSI.
|
||||
|
||||
**Injection is atomic and no longer silent.** `installAgentSkillInto()` wrote each file with a bare `writeFile`, so two sessions created concurrently in one repo could leave a reader observing a truncated SKILL.md; writes now go through temp+rename under the same lock every sibling mutator uses. And both server call sites discarded the outcome, so a `foreign` refusal (a user-authored skill is present) or a `symlink` refusal was invisible: turning the setting on, seeing nothing, and having no way to find out why. Refusals are logged now; injection stays best-effort and still cannot fail session creation.
|
||||
|
||||
**Reference corrections**: the `FORBIDDEN` 403 row and which auth responses are plain text rather than the JSON envelope, the input size cap, the undocumented `killMux` parameter on DELETE, and the fact that zero, negative and non-integer timeouts are rejected with a 400 rather than clamped.
|
||||
|
||||
**README.zh-CN.md taught a recipe that could not work**: its input example had no trailing `\r`, so Enter was never sent and the prompt sat unsubmitted, and its read step used `/output`, whose `textOutput` is always empty for interactive sessions. Its agent section is now in line with the English one. CLAUDE.md's single-line gotcha also gained the `\r` rule.
|
||||
|
||||
**Tests**: the `codeman skill install`/`uninstall` CLI had none, including the linked-case resolution shipped in 1.14.2; the `POST /api/sessions` injection call site was never exercised because the shared route mock hardcoded the gate off; and nothing guarded `reference/endpoints.md` against drifting from the routes it documents. All three covered now.
|
||||
|
||||
## Mobile browser suite
|
||||
|
||||
The suite drives a real browser against a server started from TypeScript source, so it serves `src/web/public`, while `npm run build` puts the xterm vendor bundles in `dist/web/public`. Without them every `/vendor/xterm*` request 404s, `Terminal` is never defined, and every test touching `app.terminal` dies on a null. A `pretest:mobile` step now prepares them.
|
||||
|
||||
Hardened after two review rounds, each defect reproduced: the freshness cache trusted mtime alone, so a bundle left without its alias tail (or truncated by an interrupted `npm install`) was reported "up to date" forever while the suite died on `LocalEchoOverlay is not defined`; it now verifies content and size, and repairs what an earlier run poisoned. Builds go to a temp file private to the run and rename into place, so a partial write can never be published and two concurrent runs cannot corrupt each other. Temps whose owning process is gone are reclaimed, and only those. Freshness tracks every input the bundle derives from, not just the entry, so editing a sibling of the addon no longer leaves the suite testing a stale overlay. `npx` runs with the repo as cwd, so it uses the pinned esbuild instead of fetching an unpinned one.
|
||||
|
||||
## 1.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Four reported bugs fixed, and the Codeman agent skill from 1.14.1 gets its first published build with the fixes below alongside it.
|
||||
|
||||
## The Codeman agent skill
|
||||
|
||||
Introduced in 1.14.1 and the headline of this line. `skills/codeman` is a Claude Code skill that lets an agent running **inside** a Codeman session drive the HTTP API: start worker sessions, send them prompts, block until they finish, read their answers and clean up. It ships in the npm package and self-gates, so outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and costs unrelated sessions nothing.
|
||||
|
||||
### Installing it
|
||||
|
||||
```bash
|
||||
codeman skill install # ~/.claude/skills/codeman, every new Claude Code session sees it
|
||||
codeman skill install --case myproject # just that case; linked cases resolve by name too
|
||||
codeman skill uninstall # reverses either one
|
||||
```
|
||||
|
||||
Or turn on **App Settings > Agent Skill** (`agentSkillEnabled`, synced, default off) and Codeman injects the skill into each case when a Claude session is created there.
|
||||
|
||||
Installs are marker-owned: a `skills/codeman` that Codeman did not write is never touched, a stale managed copy is refreshed in place, and a symlinked skill directory is refused rather than written through. Re-run `codeman skill install` after upgrading to refresh the copy. Turning `agentSkillEnabled` back off does **not** remove already-injected copies, because a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` directory; remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
### Using it
|
||||
|
||||
Ask for orchestration in plain language ("spin up three workers, have them lint, typecheck and test in parallel, then report back") and the skill supplies the guard, the safety rules and the recipes. The flow it runs:
|
||||
1. **Guard.** Re-runs a preamble on every shell call that refuses outside `CODEMAN_MUX=1`, reads `CODEMAN_API_URL` and `CODEMAN_SESSION_ID`, recovers a password from the data dir `.env` or the install's service definition if one is set, and defines a fail-closed `delete_session`. It re-runs it every call because shell state does not survive between an agent's tool calls.
|
||||
2. **Start a worker** with `POST /api/v1/quick-start` (`mode` is any of `claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`), checking `.success` before reading `.data.sessionId`.
|
||||
3. **Wait until it is really ready.** A new session reports `idle` before its CLI has spawned, and a brand-new case shows a trust dialog first, so the skill waits for the composer's own status bar and treats the dialog as a bounded fallback.
|
||||
4. **Send and wait in one call**: `wait`/`waitTimeout` on `POST /api/v1/sessions/:id/input`. It registers the waiter before typing, closing the race where a separate wait reports the previous turn's idle state as this turn's answer. For `claude` workers it resolves on the `stop` hook, usually within seconds.
|
||||
5. **Read the answer** from `GET /api/v1/sessions/:id/last-response`, which returns clean transcript text rather than a screen scrape.
|
||||
6. **Clean up** with `delete_session`, for ids it created and nothing else.
|
||||
|
||||
Hook-less modes (`shell` and the external CLIs) have no `stop` signal and coarse lifecycle transitions, so the skill synchronizes those with a unique split marker and `wait-output ... from=buffer`. Worked fan-out flows, the per-mode signal table, error codes and the Docker/remote caveats live in the skill's `reference/` files, loaded on demand.
|
||||
|
||||
### The rules it encodes
|
||||
|
||||
Each of these silently wastes a run, which is why they are written down: every input must end with `\r` or Enter is never sent; input is single-line; a wait timeout is HTTP 200 with `wait.timedOut`, not an error; `stop` and `blocked` are `claude`-only; signals are edge-triggered with no history, so never fire-and-forget N prompts and then gather signal-waits one by one; a typed command echoes into the output stream, so markers must be split; a full-screen TUI stream is space-less, so match single tokens; and `pid != null` proves startup, not life, so `wait?until=exit` is the death check.
|
||||
|
||||
## Bug fixes
|
||||
- **Web tabs: long-running proxied requests were aborted after 30 seconds with no server log (#237).** The proxy wrapped each upstream fetch in a 30s `AbortSignal.timeout`, which bounds the entire exchange rather than the wait for response headers, so a dashboard endpoint doing model inference and any actively streaming response both died at 30s as a generic unlogged 502 that read as an intermittent network error. The timeout now bounds time-to-headers only and is cleared the moment headers arrive, with the default raised to 300s (`CODEMAN_WEBVIEW_TIMEOUT_MS`). Header timeouts are logged with a sanitized identity (method plus origin plus path, never the query string, which can carry the dashboard's tokens). A browser that navigates away mid-request now aborts the upstream fetch, guarded by `writableFinished` so a completed response never triggers it. The WebSocket handshake keeps its own 30s budget via the new `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS`, since a handshake is connection establishment and waiting minutes on one only delays the browser's reconnect logic.
|
||||
- **Web tabs: sandbox incompatibility with cookie-authenticated reverse proxies documented (#238).** `docs/web-tabs.md` now covers cookie auth in front of Codeman itself (Cloudflare Access and similar), where a sandboxed frame's asset and API requests carry no auth cookie, bounce to the login provider, and leave the embedded app apparently unstyled while trusted mode works. The Test button's result now states its own scope: it verifies server-to-upstream reachability, not how the page behaves in a sandboxed frame.
|
||||
- **A described session tab now shows just the description (#232).** A session named `w2-foo-bar: some description` rendered both halves, so the generated id ate the width the chosen part needed. The tab shows the description alone, the `w<n>-<case>` id moves to the tooltip and stays in the session settings modal, and `aria-label` deliberately keeps the full name so screen readers still get the id. Undescribed tabs are unchanged. Right-click a tab to rename it inline. This also fixed a re-render loop: the incremental update compared against the full name, which a described tab never matched, so those tabs re-rendered on every pass.
|
||||
- **`codeman status` now probes the running server (#230).** The command runs in its own fresh process and reported that process's always-stopped Ralph loop under a bare "Status:", which reads as "the server is down" while the service is running fine and agents are reachable. It now probes the real server (`CODEMAN_API_URL`, else https then http on the local port, overridable with `--url`) and reports reachability, version and live session state; any HTTP answer proves the server is up, including a 401 from a password-protected install. The Ralph loop keeps its own `codeman ralph status`. This complements `codeman web --status` from the daemon work: that answers "did I start a daemon", this answers "is a server running at all".
|
||||
|
||||
## 1.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- The Codeman agent skill is now installable, so an agent running inside a Codeman session can drive the API without you pasting docs into its prompt. Plus six fixes to the packaged skill, each found by running it live against a real instance.
|
||||
|
||||
## What the skill is
|
||||
|
||||
`skills/codeman` is a Claude Code skill that teaches an agent inside a Codeman session how to start worker sessions, send them prompts, block until they finish, read their answers and clean up. It ships in the npm package. It self-gates: outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act, so installing it globally costs unrelated sessions nothing.
|
||||
|
||||
## Installing it
|
||||
|
||||
Three ways, pick one:
|
||||
|
||||
```bash
|
||||
codeman skill install # ~/.claude/skills/codeman, every new Claude Code session sees it
|
||||
codeman skill install --case myproject # just that case; linked cases resolve by name too
|
||||
codeman skill uninstall # reverses either one
|
||||
```
|
||||
|
||||
Or turn on **App Settings > Agent Skill** (`agentSkillEnabled`, synced, default off) and Codeman injects the skill into each case when a Claude session is created there.
|
||||
|
||||
Installs are marker-owned: a `skills/codeman` that Codeman did not write is never touched, a stale managed copy is refreshed in place, and a symlinked skill directory is refused rather than written through. Re-run `codeman skill install` after upgrading Codeman to refresh the copy.
|
||||
|
||||
Note that turning `agentSkillEnabled` back off does **not** remove already-injected copies, because a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` directory. Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
## Using it
|
||||
|
||||
Once installed, just ask: "spin up three workers and have them lint, typecheck and test in parallel, then report back". The skill supplies the guard, the safety rules and the recipes. What it does under the hood:
|
||||
|
||||
**1. Guard.** Every Bash call re-runs a preamble that refuses outside `CODEMAN_MUX=1`, reads `CODEMAN_API_URL` and `CODEMAN_SESSION_ID`, recovers a password from the data dir `.env` or the install's service definition if one is set, and defines a fail-closed `delete_session`. It re-runs it every call because shell state does not survive between an agent's tool calls.
|
||||
|
||||
**2. Start a worker.**
|
||||
|
||||
```bash
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
```
|
||||
|
||||
`mode` is any of `claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`.
|
||||
|
||||
**3. Wait until it is actually ready.** A new session reports `idle` before its CLI has spawned, and a brand-new case shows a trust dialog first, so the skill waits for the composer's own status bar and treats the dialog as a bounded fallback.
|
||||
|
||||
**4. Send a prompt and wait for the turn to end.**
|
||||
|
||||
```bash
|
||||
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"codeman-agent-1",seq:1,wait:true,waitTimeout:60000}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' --data-binary "$BODY"
|
||||
```
|
||||
|
||||
Send-and-wait registers the waiter before typing, which closes the race where a separate wait reports the previous turn's idle state as this turn's answer. For `claude` workers it resolves on the `stop` hook, typically within seconds.
|
||||
|
||||
**5. Read the answer.**
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text'
|
||||
```
|
||||
|
||||
**6. Clean up.** `delete_session "$SID"`, for ids you created and nothing else.
|
||||
|
||||
Hook-less modes (`shell` and the external CLIs) have no `stop` signal and coarse lifecycle transitions, so the skill synchronizes those with a unique split marker and `wait-output ... from=buffer` instead. Worked fan-out flows, the per-mode signal table, error codes and the Docker/remote caveats live in the skill's `reference/` files, loaded on demand.
|
||||
|
||||
## The rules that bite
|
||||
|
||||
The skill documents these because each one silently wastes a run:
|
||||
- **Every input must end with `\r`** or Enter is never sent and the text sits unsubmitted on the worker's prompt. `delivered:true` means "written to the pane", not "submitted".
|
||||
- **Input is single-line.** Newlines are stripped.
|
||||
- **A wait timeout is HTTP 200** with `wait.timedOut:true`, not an error. Loop over short waits; timeouts clamp to [1s, 600s] and the applied value comes back as `wait.timeoutMs`.
|
||||
- **`stop` and `blocked` are `claude`-only.** Requesting them elsewhere is a 400.
|
||||
- **Signals are edge-triggered with no history.** One that fires while no waiter is registered is unobservable afterwards, so never fire-and-forget N prompts and then gather signal-waits worker by worker.
|
||||
- **Your typed command echoes into the output stream**, so a marker that appears verbatim in the input line matches before the command runs. Split it.
|
||||
- **A full-screen TUI stream is space-less**, so match a single space-free token, never a phrase.
|
||||
- **`pid != null` proves startup, not life.** A worker that dies inside its pane keeps `status:"idle"` and a pid. `wait?until=exit` is the death check.
|
||||
|
||||
## Fixes to the packaged skill
|
||||
- **The self-delete guard failed open.** The old `is_self "$SID" || curl -X DELETE ...` shape meant an undefined `is_self` exited 127, the `||` branch fired, and the agent deleted its own session with the one guard bypassed. That is reachable because shell state does not survive between tool calls, so a partially re-pasted preamble was enough. The DELETE now lives inside a fail-closed `delete_session`, which also refuses an empty id and refuses when `$SELF` is unset or too short to prove the target is not the caller.
|
||||
- **`clientId` was built from `$$`.** The pid changes between tool calls, so the documented "resend the identical request" loop stopped being recognized as a duplicate and retyped the prompt, submitting the turn twice. It is a fixed literal now.
|
||||
- **`GET /api/v1/sessions/:id/last-response` was undocumented.** It returns the agent's final message as clean transcript text; the terminal scrape the skill previously recommended returns a wall of TUI repaint noise with the answer buried in it. It is now the documented read path for `claude` and `codex`, with the terminal buffer demoted to diagnosis and hook-less modes. Because the transcript flush lags the `stop` signal, the recipes poll it instead of reading once.
|
||||
- **`quick-start` responses were never checked for `.success`.** On failure `.data.sessionId` is absent, `jq -r` prints the string `null`, and the flow burned its full readiness budget against `/api/v1/sessions/null` before reporting jq noise instead of the cause.
|
||||
- **`codeman skill install --case <name>` could not resolve a linked case.** It hardcoded `~/codeman-cases/<name>` while the server resolves through `linked-cases.json` first, so it failed with "Case not found" for a case the web UI handled fine.
|
||||
- **Documentation corrections**: `SESSION_BUSY` on `quick-start` is the 50-session cap rather than the waiter cap; `caseName` resolves linked cases, so a generic name can land a worker in a real repo; and the claim that a toggle-off sweep exists was wrong, so the per-case `skill uninstall` cleanup is now stated in both the README and the code.
|
||||
|
||||
## Also in this release
|
||||
- **Terminal**: the wheel is no longer forwarded to codex, which ignores SGR mouse reports.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Daemon mode and service install, plus subagent hook hardening and terminal/idle-checker fixes.
|
||||
|
||||
**New: run Codeman in the background without a terminal (#239, closes #231)**
|
||||
- `codeman web -d` starts the server detached: it survives closing the shell, logs to `~/.codeman/web.log`, records a pidfile, and only reports success after the server actually answers `/api/status` (a port clash or missing dependency can never read as a clean start). `codeman web --status` and `codeman web --stop` manage it; `--stop` verifies the pid still looks like a Codeman server before signalling, so a recycled pid is never SIGTERMed.
|
||||
- `codeman service install` / `status` / `uninstall`: installs a systemd user unit (Linux) or LaunchAgent (macOS) so the server comes back after reboots. The unit carries the installing shell's PATH (launchd's default PATH finds neither an nvm/Homebrew `node` nor `tmux`/`claude`), never contains `CODEMAN_PASSWORD`, and uses the same instance-scoped unit names as `install.sh` and the self-updater so no second copy can end up supervised.
|
||||
- Both refuse to start a second server on one data dir (pidfile check plus a live probe): two servers on the shared tmux socket would attach to each other's sessions.
|
||||
- Why `-d` exists at all: `nohup` does not protect a Node process, Node re-arms SIGHUP even when it inherits "ignore", so `nohup codeman web &` still dies on HUP. The detached relaunch (setsid) removes the controlling terminal instead.
|
||||
|
||||
**Subagent background-work hooks (#233, thanks @Lint111)**
|
||||
- The background Bash rewake helper now also watches the top-level parent transcript when the hook fires inside a subagent: Claude records a subagent's Bash result in its own `subagents/agent-*.jsonl` but queues the completion in the lead session transcript, so subagents previously never woke. It can also inline a `CODEMAN_RESULT_BEGIN/END` marked report (up to 64 KiB) from the task output file into the wake feedback.
|
||||
- New SubagentStop guard: a subagent that still owns live Monitor or background Bash processes is kept working instead of publishing an intermediate progress line as its final report. Ownership is verified against live process descriptors on `tasks/<id>.output`, so stale transcript text alone never blocks, and the guard fails open on systems without `/proc`.
|
||||
- Existing cases self-heal to the new hooks on next launch.
|
||||
|
||||
**AI idle checker: stderr kept out of the verdict (#234, thanks @Lint111)**
|
||||
|
||||
The `claude -p` verdict command no longer merges stderr into the verdict file, where CLI warnings could turn a valid verdict into a parse error. On failures, the first 200 chars of stderr are attached to the diagnostic instead.
|
||||
|
||||
**Terminal: large final batches drain fully (#235, thanks @Lint111)**
|
||||
|
||||
A render-scheduling flag was cleared after the flush instead of before it, so when a large batch left a remainder behind, the remainder stayed unrendered until unrelated output arrived. This looked like truncated responses or shell commands that never finish. The flush now reschedules itself until the queue is empty.
|
||||
|
||||
**Docs and tests**
|
||||
- README documents daemon mode and service install.
|
||||
- Unique test port for the daemon-control suite.
|
||||
|
||||
## 1.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Agent wait primitives, the Codeman agent skill, a fix for hooks dying silently on HTTPS installs, and the tab-strip UX improvements from the previous batch.
|
||||
|
||||
**Agent wait primitives (new API surface, the reason this is a minor).** Three bounded long-polls let an agent driving Codeman from a shell block instead of poll:
|
||||
- `GET /api/v1/sessions/:id/wait` blocks until a lifecycle signal fires (`until=stop,idle,working,blocked,exit`, `fresh=1` to require a new transition).
|
||||
- `GET /api/v1/sessions/:id/wait-output` blocks until a literal substring appears in the session's output (`match=`, `nocase=`, `from=now|buffer`; never regex, by design).
|
||||
- `wait`/`waitTimeout` on `POST /api/v1/sessions/:id/input` (send-and-wait) registers the waiter before typing, closing the race where a separate wait reports the previous turn's idle state as this turn's answer.
|
||||
|
||||
Shared semantics: a timeout is HTTP 200 with `wait.timedOut: true` (callers loop over short waits; tunnels cut idle connections), timeouts are clamped to [1s, 600s] and echoed back as `wait.timeoutMs`, all three nest the result under `data.wait`, and `status`/`limitPaused` ride along. `stop`/`blocked` exist for `claude` mode only: requesting them explicitly elsewhere is a 400, the default set silently narrows and echoes what it waited on. Capacity caps (16 waiters per session, 128 process-wide) answer 409/429, waiter slots release on client hang-up, and shutdown resolves parked waiters instead of stranding them. Bounds are operator-tunable via `CODEMAN_WAIT_*` env vars.
|
||||
|
||||
Reliability details that came out of three verification rounds: a worker that dies inside its tmux pane is now detected at the mux layer (pane-death probe, ~750ms cache, a 3s watcher for waits already parked), so a corpse answers `exit` instead of `idle` and send-and-wait rolls back its dedup seq when the write went nowhere; output matching normalizes charset-designation escapes (a stock bash prompt's `ESC ( B` no longer breaks `match=tnode:`) and holds back partial escapes at chunk boundaries, so matches straddling PTY chunks are found.
|
||||
|
||||
**Codeman agent skill (`skills/codeman`).** A packaged skill that teaches an agent running inside a Codeman session to drive the API safely: guard preamble (refuses outside `CODEMAN_MUX=1`, resolves credentials from the data dir `.env` or the install's service definition), self-protection (`is_self` prefix check in both directions), readiness for claude workers (composer-first, trust dialog as bounded fallback), send-and-wait loops that cannot report a never-submitted prompt as success, marker-synchronized shell flows, fan-out patterns, and cleanup discipline. Ships in the npm package via the `files` entry.
|
||||
|
||||
**Hooks were dying silently on every HTTPS install (bug fix).** The generated hook curls lacked `-k`, so on `--https` installs (self-signed cert) every hook event (`stop`, `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `teammate_idle`, `task_completed`) failed TLS verification and the failure was swallowed, taking respawn's definitive idle signals with it. Hooks are now generated with `curl -sk`, and a staleness detector regenerates the on-disk hook config of already-created cases the next time a session starts in them. Relatedly, `CODEMAN_API_URL` is no longer exported with a guessed `http://localhost:3000` fallback (wrong scheme on HTTPS installs); it is omitted unless the server has stamped the real URL, so in-session guards fail closed.
|
||||
|
||||
**Tab strip (from the previous batch, reported by christianhaberl):** action icons (kill/pop-out) now appear on the active tab only, middle-click closes a tab, tab hover uses a fixed width with a sliding title instead of resizing the strip, and the pop-out button is opt-in (default off).
|
||||
|
||||
**Docs.** `docs/api-reference.md` gained the full long-polling contract (signals by mode, readiness, what the matcher sees, response discriminators); `docs/extending-codeman.md` and the README carry verified copy-paste orchestration recipes; `docs/architecture-invariants.md` records the load-bearing ordering, liveness, and edge-triggered-signal invariants. Net +163 tests (4300 passing in the CI sweep).
|
||||
|
||||
## 1.12.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Codex input fixes: all four bugs reported by @DodgyBadger traced to one root cause (the zero-lag local-echo overlay buffering keystrokes until Enter, which starves codex's per-keystroke composer) and fixed in terminal-ui.js:
|
||||
- Slash command picker never appeared in codex sessions (#222): the "/" sat in the overlay until Enter, so codex never saw it. Codex-mode sessions now use plain PTY echo (same branch as shell), so the picker pops and live-filters as you type.
|
||||
- Arrow keys dead while typing, backspace dead after Ctrl+Backspace (#218): arrows were forwarded to a still-empty composer while typed text sat pending, and after a control-char flush the overlay swallowed every backspace. Codex bypasses the overlay entirely now; the shared overlay branch (claude/gemini/opencode) additionally flushes pending text on composer nav keys, then hands the session to pass-through until Enter/Ctrl+C, and forwards backspace instead of swallowing it when the overlay has no state.
|
||||
- Pasting displaced the typed prompt (#219): bracketed pastes (xterm terminal.paste with DECSET 2004 active) were forwarded without flushing pending typed text, so the paste landed first. The shared branch now flushes typed text first and delays the paste sequence by 80ms, because codex's paste-burst handling drops keystrokes that arrive in the same PTY read as a bracketed paste (verified against codex 0.147.0 at the byte level).
|
||||
- Long prompts overflowed the bottom of the screen (#220): long typed prompts existed only in the overlay DOM so codex never grew its composer; with plain PTY echo the composer grows and rewraps normally.
|
||||
|
||||
Verified end to end against a real codex 0.147.0 TUI driven by a headless browser: the pre-fix build reproduces all four bugs, the fixed build passes 17/17 assertions. New CI test file test/local-echo-codex-gating.test.ts (41 tests) pins the nav-key classifier, per-mode overlay gating, the flush helper, and pass-through routing. Known upstream limitation: Ctrl+Backspace deletes one character, not a word (xterm.js sends 0x08; word-delete needs kitty CSI-u encoding that xterm.js 6.0.0 cannot emit).
|
||||
|
||||
Mobile keyboard viewport settling fixes by @Lint111 (#229): coalesce keyboard viewport settling so rapid visualViewport resize events during keyboard show/hide no longer thrash the terminal fit, and only arm the settle logic on a real keyboard transition instead of every viewport resize.
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal scrollback fixes, round 2 of issue #205. A Claude pane's local buffer is hollow (tmux keeps no history for a repaint-mode pane), and both retest reports traced back to that fact. The scroll-to-top full-history re-pull now refuses to rewrite the terminal when the capture holds less than the browser already does, so it can no longer delete history mid-scroll on iPhone (a refused session also re-fetches far less often). When wheel-forwarding is unavailable on a Claude session (version probe failed, CLI older than 2.1.187, or the "Wheel Scrolls Local History" opt-out) and there is no local scrollback to scroll, wheel and touch now page the CLI's own transcript via coalesced PageUp/PageDown instead of doing nothing. The `claude --version` probe no longer caches a failed run for the server's lifetime (one timed-out probe used to silently disable wheel-forwarding on every device until restart); failures retry with backoff. Every scroll gesture now logs a one-line `[scroll]` routing decision to the browser console for direct diagnosis, and the opt-out setting's tooltip explains that the paging fallback is Claude-only (Codex has none).
|
||||
- 2e69e28: Bound the process-tree walk that could take a machine down.
|
||||
|
||||
`getChildPids` ran `pgrep -P <pid>` per node and recursed with no visited set, no
|
||||
depth limit and no node cap. Across ~28 adopted tmux trees the fan-out exploded,
|
||||
and because each `pgrep` blocks in the kernel while reading `/proc/<pid>/cgroup`
|
||||
under WSL, none returned while the walk kept spawning more — ~13,000 `pgrep`
|
||||
processes stuck in D-state out of ~39,000 total, load average above 13,000,
|
||||
recoverable only by restarting WSL.
|
||||
|
||||
Now: one `ps` snapshot, breadth-first with a visited set, a depth cap and a node
|
||||
cap, in a pure module (`proc-tree.ts`) that the regression tests exercise
|
||||
directly. The snapshot is refreshed asynchronously, and the kill path forces a
|
||||
fresh one so the SIGKILL escalation cannot re-read pre-SIGTERM state.
|
||||
|
||||
- ebfcac6: An input whose delivery fails can be retried instead of being lost for good.
|
||||
|
||||
Both input paths recorded the `(clientId, seq)` pair as applied and acknowledged
|
||||
the frame _before_ knowing whether the write had landed — the POST route because
|
||||
its mux write is fire-and-forget, the WebSocket handler because it ACKed
|
||||
unconditionally. When the write then failed, the client dropped the frame from its
|
||||
durable queue and the server rejected the retry as a duplicate: the reliable
|
||||
delivery layer was guaranteeing exactly-once delivery of something that had never
|
||||
been delivered.
|
||||
|
||||
The bookkeeping is now rolled back on failure and the WebSocket ACK withheld, so
|
||||
the client redelivers. `Session.write()` reports whether it reached a PTY at all
|
||||
instead of silently swallowing the data.
|
||||
|
||||
Response codes are unchanged: a session can legitimately have no PTY yet (created
|
||||
but not started), so turning that into a failure status would be a contract change
|
||||
of its own.
|
||||
|
||||
Note this does not remove the root cause: the POST still answers 200 before the
|
||||
mux write is attempted, so a client that treats any 2xx as final still cannot
|
||||
learn about that failure. Closing that would mean awaiting the tmux child in the
|
||||
request path.
|
||||
|
||||
- 1a32e63: Routes that answer with `reply.raw.writeHead()` no longer drop the headers the
|
||||
security hook set.
|
||||
|
||||
`writeHead` writes straight to the Node response and bypasses Fastify's header
|
||||
store, so everything the `onRequest` hook granted was silently lost — including the
|
||||
`Access-Control-Allow-Origin` it emits for localhost origins, and the
|
||||
`X-Content-Type-Options` / `X-Frame-Options` / CSP headers. A localhost page could
|
||||
therefore call every other `/api` endpoint cross-origin while its EventSource
|
||||
failed CORS.
|
||||
|
||||
Affects `GET /api/events` and the three raw-writing routes in `file-routes.ts`
|
||||
(`file-raw`, `tail-file`, `download`).
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -74,7 +74,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.
|
||||
|
||||
**Version**: 1.12.0 (must match `package.json`)
|
||||
**Version**: 1.15.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -109,6 +109,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| CI-equivalent test sweep | `npm run test:ci` (full suite minus browser/perf — see Testing) |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
|
||||
| Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `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 3 Playwright tests). Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing).
|
||||
|
||||
@@ -118,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **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)
|
||||
@@ -127,7 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **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
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. 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). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-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
|
||||
- **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.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', …)`. → [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. ⚠️ 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)
|
||||
@@ -141,7 +143,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
| Domain | Key files | Notes |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts` | |
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names` | The last three back `web -d` / `service install` |
|
||||
| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
@@ -180,6 +182,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case <name>]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
@@ -194,7 +198,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
||||
|
||||
**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 sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
@@ -206,9 +210,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)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). → [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** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it 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** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) 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 → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
@@ -292,7 +298,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~199 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~200 handlers across 21 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -85,9 +85,29 @@ codeman web --multiuser # named logins + per-user case spaces
|
||||
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
|
||||
|
||||
<details>
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
<summary><strong>Keep it running in the background</strong></summary>
|
||||
|
||||
The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead:
|
||||
To outlive the shell you started it in, without setting anything up:
|
||||
|
||||
```bash
|
||||
codeman web -d # detach; logs to ~/.codeman/web.log
|
||||
codeman web --status # is it up, and on which pid
|
||||
codeman web --stop # graceful SIGTERM; agents keep running in tmux
|
||||
```
|
||||
|
||||
`-d` waits until the server actually answers before reporting success, and refuses to start a second one on the same data dir (two servers sharing a tmux socket attach to each other's sessions).
|
||||
|
||||
To have it come back after a reboot, install it as a service instead. The installer's final menu does this for you (option 2); `codeman service` is the equivalent for an `npm i -g aicodeman` install:
|
||||
|
||||
```bash
|
||||
codeman service install # systemd user unit (Linux) or LaunchAgent (macOS)
|
||||
codeman service status
|
||||
codeman service uninstall
|
||||
```
|
||||
|
||||
`service install` writes the unit with your current PATH baked in, which matters more than it sounds: launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`, so a Homebrew or nvm `node`, `tmux` or `claude` is invisible to a hand-written plist. It never copies `CODEMAN_PASSWORD` into the unit file; add that yourself if the service needs auth.
|
||||
|
||||
To write the unit by hand instead:
|
||||
|
||||
**Linux (systemd):**
|
||||
|
||||
@@ -220,6 +240,8 @@ codeman web # localhost:3000 (loopback only — safe defau
|
||||
codeman web --port 8080 # custom port (or set CODEMAN_PORT)
|
||||
codeman web --https # self-signed TLS (only needed for remote access)
|
||||
codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security)
|
||||
codeman web -d # detach: survives closing the shell (--status, --stop)
|
||||
codeman service install # systemd/launchd service: comes back after reboots
|
||||
```
|
||||
|
||||
Open the printed URL. The page is a single dashboard; everything below happens there.
|
||||
@@ -268,6 +290,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
||||
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
|
||||
@@ -403,6 +426,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
## 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
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → 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)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
@@ -667,6 +691,18 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
|
||||
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running _inside_ a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
|
||||
|
||||
> **Shortcut: install the packaged agent skill.** Everything below (plus worked multi-worker recipes) ships as a Claude Code skill in [`skills/codeman`](skills/codeman/SKILL.md), so an agent inside a session can drive Codeman without you pasting docs into the prompt. Three ways to get it:
|
||||
>
|
||||
> - `npx skills add Ark0N/Codeman --skill codeman -g`: global, works for any skills-aware agent
|
||||
> - `codeman skill install` (global) or `codeman skill install --case <name>`: for npm installs that never cloned the repo; `codeman skill uninstall` reverses it
|
||||
> - **App Settings → Agent Skill** (`agentSkillEnabled`, default off): Codeman then injects the skill into each case on Claude session create; a user-authored `skills/codeman` in the case is never overwritten
|
||||
>
|
||||
> A global install (`codeman skill install`, or `npx skills add`) is picked up by **every new Claude Code session on the machine**, inside Codeman or not. The skill self-gates: outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act, so a global install costs an idle session nothing.
|
||||
>
|
||||
> ⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` dir). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
|
||||
|
||||
### Detect that you're inside Codeman
|
||||
|
||||
When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything:
|
||||
@@ -680,15 +716,21 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
|
||||
|
||||
### Rules of the road (read before you POST)
|
||||
|
||||
1. **Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls.
|
||||
1. **Single-line input, ending in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs the joined command `echo Aecho B`: send one line per call.
|
||||
2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
|
||||
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard).
|
||||
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). ⚠️ A `401` replies with the bare string `Unauthorized`, **not** the JSON envelope, so piping it into `jq` throws a parse error instead of showing the failure: check the status before parsing.
|
||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
||||
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
|
||||
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
|
||||
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
|
||||
|
||||
### Recipes
|
||||
|
||||
```bash
|
||||
# CODEMAN_API_URL is auto-set inside every Codeman session, correct scheme included.
|
||||
# The fallback below fits a stock install; on a --https install set the https:// URL
|
||||
# yourself and add -k to each curl (self-signed cert).
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
|
||||
# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set)
|
||||
|
||||
@@ -700,18 +742,63 @@ curl -s -X POST "$API/api/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
|
||||
|
||||
# 2b. Wait until that worker is actually READY (see rule 8): composer marker first,
|
||||
# first-run trust dialog only as the fallback. (Probing trust first and sending
|
||||
# a blind Enter misfires on re-runs: the dialog text stays in the buffer forever,
|
||||
# so the probe matches stale text and the Enter lands in a ready composer.)
|
||||
# Match single tokens: TUI text can reach the matcher without its spaces.
|
||||
until [ "$(curl -s "$API/api/sessions/$SID" | jq '.data.pid')" != null ]; do sleep 1; done
|
||||
R=$(curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=bypass' \
|
||||
--data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
T=$(curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=trust' \
|
||||
--data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"\r","useMux":true}' # accept the first-run trust dialog
|
||||
curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=bypass' \
|
||||
--data-urlencode 'from=buffer' --data-urlencode 'timeout=45000' >/dev/null
|
||||
fi
|
||||
|
||||
# 3. Send a prompt into a session (exactly-once: clientId + seq)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
-d '{"input":"Run the test suite and summarize failures\r","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
|
||||
# 4. Read the terminal back
|
||||
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
|
||||
# 4. Send a prompt and BLOCK until that turn is done (registers the wait before
|
||||
# writing, so it can't answer with the previous turn's idle state)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures\r","useMux":true,
|
||||
"clientId":"agent-1","seq":2,"wait":"stop,exit","waitTimeout":60000}' \
|
||||
| jq '.data.wait' # -> {"signal":"stop","timedOut":false,"waitedMs":41230,...}
|
||||
# (`stop` is the definitive end-of-turn hook. Adding `idle` makes it resolve on a
|
||||
# spinner pause too, and on anything that redraws a ❯ prompt — like a dialog.)
|
||||
|
||||
# 5. Stream live events (session output, agent activity, status)
|
||||
# 4b. Timed out? That's a 200, not a failure. Loop over short waits.
|
||||
curl -s "$API/api/sessions/$SID/wait?until=stop,exit&timeout=60000" | jq '.data.wait'
|
||||
|
||||
# 4c. Or wait for a marker in the output (works for shell sessions too).
|
||||
# ⚠️ Unique per call (tmux repaints replay old screen text), and SPLIT so the
|
||||
# typed line never contains it: your own keystrokes echo into the output
|
||||
# stream, so an unsplit marker matches before the command has run. from=buffer
|
||||
# catches a marker that printed before the wait landed.
|
||||
N=$RANDOM
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d "{\"input\":\"M=DONE; npm test; echo \${M}_$N rc=\$?\r\",\"useMux\":true}"
|
||||
curl -sG "$API/api/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
||||
|
||||
# 5. Read the terminal back. ⚠️ Use terminal?tail=, NOT /output: the latter's
|
||||
# textOutput is empty for every tmux-backed (i.e. every interactive) session.
|
||||
# tail counts BYTES, and what comes back is terminal data, ANSI included.
|
||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||
|
||||
# 6. Stream live events (session output, agent activity, status)
|
||||
curl -sN "$API/api/events" # Server-Sent Events
|
||||
|
||||
# 6. Schedule recurring work (cron-style job)
|
||||
# 7. Schedule recurring work (cron-style job)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
|
||||
@@ -719,11 +806,11 @@ curl -s -X POST "$API/api/cron/jobs" \
|
||||
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
|
||||
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
|
||||
|
||||
# 7. Inspect background sub-agents and their transcripts
|
||||
# 8. Inspect background sub-agents and their transcripts
|
||||
curl -s "$API/api/subagents" | jq '.data // .'
|
||||
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
|
||||
|
||||
# 8. Whole-system snapshot (sessions, settings, respawn, stats)
|
||||
# 9. Whole-system snapshot (sessions, settings, respawn, stats)
|
||||
curl -s "$API/api/status" | jq
|
||||
```
|
||||
|
||||
@@ -749,7 +836,7 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
|
||||
|
||||
## API
|
||||
|
||||
REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
||||
REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
||||
|
||||
### Sessions
|
||||
|
||||
@@ -757,8 +844,11 @@ REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE str
|
||||
| -------- | -------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/sessions` | List all |
|
||||
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) |
|
||||
| `GET` | `/api/sessions/:id/output` | Read terminal output |
|
||||
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`: `clientId`+`seq` = exactly-once; `wait` blocks until the turn ends) |
|
||||
| `GET` | `/api/sessions/:id/terminal` | Read terminal output (`?tail=<bytes>`, `?full=1`); the read path for interactive sessions |
|
||||
| `GET` | `/api/sessions/:id/output` | Parsed one-shot output (`textOutput` is empty for tmux-backed sessions) |
|
||||
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
|
||||
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
|
||||
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
|
||||
|
||||
+90
-20
@@ -657,6 +657,16 @@ sc -l # 列出会话
|
||||
|
||||
面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。
|
||||
|
||||
> **捷径:装上打包好的智能体技能。** 下面这一整套(外加多工作会话的实战配方)已经作为 Claude Code 技能随仓库发布在 [`skills/codeman`](skills/codeman/SKILL.md),会话内部的智能体不必等你把文档粘进提示词就能驱动 Codeman。三种获取方式:
|
||||
>
|
||||
> - `npx skills add Ark0N/Codeman --skill codeman -g`:全局安装,任何支持技能的智能体都能用
|
||||
> - `codeman skill install`(全局)或 `codeman skill install --case <name>`:给那些从 npm 安装、从未克隆过仓库的用户;`codeman skill uninstall` 可撤销
|
||||
> - **App Settings → Agent Skill**(`agentSkillEnabled`,默认关闭):开启后,Codeman 会在每次于某个 case 中创建 Claude 会话时把技能注入该 case;case 里用户自己写的 `skills/codeman` 永远不会被覆盖
|
||||
>
|
||||
> 全局安装(`codeman skill install` 或 `npx skills add`)会被**本机每一个新建的 Claude Code 会话**读到,无论它在不在 Codeman 里。技能自带门禁:不在 Codeman 会话中(`CODEMAN_MUX` 未设置)时它拒绝动作,所以全局装上它对无关会话没有代价。
|
||||
>
|
||||
> ⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个 `.claude/` 目录的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
|
||||
### 检测自己身处 Codeman 内部
|
||||
|
||||
当 CLI 运行在 Codeman 受管会话中时,以下环境变量会被设置 —— 读取它们,别硬编码任何东西:
|
||||
@@ -670,15 +680,21 @@ sc -l # 列出会话
|
||||
|
||||
### 行路规则(POST 之前先读)
|
||||
|
||||
1. **只发单行输入。** 编程输入会作为字面文本 **+ Enter** 一次性发送。多行字符串会破坏智能体 TUI(Ink)—— 发送一行,或拆成多次调用。
|
||||
1. **只发单行输入,而且必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。内嵌的换行会被剥掉而不是报错,因此 `"echo A\necho B\r"` 执行的是拼起来的 `echo Aecho B`:一次调用只发一行。
|
||||
2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。
|
||||
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。
|
||||
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。⚠️ `401` 回的是裸字符串 `Unauthorized`,**不是** JSON 信封,直接喂给 `jq` 只会抛解析错误而看不到真正的失败原因:先看状态码,再解析。
|
||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
||||
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
|
||||
7. **只有 `claude` 会话会发出 `stop` 与 `blocked`。** 这两个来自 Claude Code hook;`shell` 与外部 CLI(opencode/codex/gemini/antigravity)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
|
||||
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
|
||||
|
||||
### 常用配方
|
||||
|
||||
```bash
|
||||
# 每个 Codeman 会话里都自动设好了 CODEMAN_API_URL,协议也是对的。
|
||||
# 下面的兜底值适用于标准安装;在 --https 安装上请自己写 https:// 的地址,
|
||||
# 并给每个 curl 加上 -k(自签名证书)。
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
|
||||
# (若设置了密码,给每个调用加上 -u admin:"$CODEMAN_PASSWORD")
|
||||
|
||||
@@ -690,18 +706,69 @@ curl -s -X POST "$API/api/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
|
||||
|
||||
# 2b. 等这个工作会话真正就绪(见规则 8):先探输入框的标记,信任对话框只作兜底。
|
||||
# (反过来先探信任对话框、再盲发一个 Enter,在重复运行时会误伤:对话框的文字
|
||||
# 会一直留在缓冲区里,探测因此匹配到旧文本,而那个 Enter 落进了已经就绪的输入框。)
|
||||
# 匹配单个词:TUI 的文字到达匹配器时可能已经丢掉了词间空格。
|
||||
until [ "$(curl -s "$API/api/sessions/$SID" | jq '.data.pid')" != null ]; do sleep 1; done
|
||||
R=$(curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=bypass' \
|
||||
--data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
T=$(curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=trust' \
|
||||
--data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"\r","useMux":true}' # 接受首次运行的信任对话框
|
||||
curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=bypass' \
|
||||
--data-urlencode 'from=buffer' --data-urlencode 'timeout=45000' >/dev/null
|
||||
fi
|
||||
|
||||
# 3. 向会话发送提示(精确一次:clientId + seq)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
-d '{"input":"Run the test suite and summarize failures\r","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
|
||||
# 4. 读回终端内容
|
||||
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
|
||||
# 4. 发送提示并阻塞到这一回合结束(先注册等待再写入,因此不会拿上一回合的状态来应答)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures\r","useMux":true,
|
||||
"clientId":"agent-1","seq":2,"wait":"stop,exit","waitTimeout":60000}' \
|
||||
| jq '.data.wait' # -> {"signal":"stop","timedOut":false,"waitedMs":41230,...}
|
||||
# (`stop` 是回合结束的权威 hook。加上 `idle` 会让它在转圈停顿时也解除,
|
||||
# 任何重画出 ❯ 提示符的东西同理,比如一个对话框。)
|
||||
|
||||
# 5. 流式接收实时事件(会话输出、智能体活动、状态)
|
||||
# 4b. 超时了?那是 200,不是失败。循环调用短等待即可。
|
||||
curl -s "$API/api/sessions/$SID/wait?until=stop,exit&timeout=60000" | jq '.data.wait'
|
||||
|
||||
# 4c. 或者等输出里出现某个标记(shell 会话也适用)。
|
||||
# ⚠️ 每次调用都要用不同的标记(tmux 重画会重放旧屏幕文字),并且把标记拆开写,
|
||||
# 让敲进去的那一行本身不包含它:你自己的按键会回显进输出流,不拆开的标记会在
|
||||
# 命令还没跑之前就匹配上。from=buffer 用来接住在等待落地之前就已打印的标记。
|
||||
N=$RANDOM
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d "{\"input\":\"M=DONE; npm test; echo \${M}_$N rc=\$?\r\",\"useMux\":true}"
|
||||
curl -sG "$API/api/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
||||
|
||||
# 5. 读回答案。claude / codex 会话用 last-response:它取自 transcript 而不是屏幕,
|
||||
# 因此不带 TUI 的画框与重画噪声。⚠️ 要轮询,别只读一次:transcript 落盘比 stop
|
||||
# 信号稍晚,紧跟着「发送并等待」返回后立刻读,常常拿到空串。
|
||||
for _ in $(seq 1 10); do
|
||||
TXT=$(curl -s "$API/api/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
|
||||
# 5b. 其他模式(shell/opencode/gemini/antigravity)没有 transcript,读终端。
|
||||
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||
|
||||
# 6. 流式接收实时事件(会话输出、智能体活动、状态)
|
||||
curl -sN "$API/api/events" # Server-Sent Events
|
||||
|
||||
# 6. 调度周期性工作(cron 风格任务)
|
||||
# 7. 调度周期性工作(cron 风格任务)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
|
||||
@@ -709,11 +776,11 @@ curl -s -X POST "$API/api/cron/jobs" \
|
||||
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
|
||||
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
|
||||
|
||||
# 7. 查看后台子智能体及其活动记录
|
||||
# 8. 查看后台子智能体及其活动记录
|
||||
curl -s "$API/api/subagents" | jq '.data // .'
|
||||
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
|
||||
|
||||
# 8. 全系统快照(会话、设置、重生、统计)
|
||||
# 9. 全系统快照(会话、设置、重生、统计)
|
||||
curl -s "$API/api/status" | jq
|
||||
```
|
||||
|
||||
@@ -739,20 +806,23 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
||||
|
||||
## API
|
||||
|
||||
基于 Fastify 的 REST —— **20 个路由模块中约 190 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||
基于 Fastify 的 REST —— **21 个路由模块中约 200 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||
|
||||
### 会话(Sessions)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| -------- | -------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?}` —— `clientId`+`seq` = 精确一次) |
|
||||
| `GET` | `/api/sessions/:id/output` | 读取终端输出 |
|
||||
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
| 方法 | 端点 | 说明 |
|
||||
| -------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`:`clientId`+`seq` = 精确一次;`wait` 阻塞到这一回合结束) |
|
||||
| `GET` | `/api/sessions/:id/terminal` | 读取终端输出(`?tail=<bytes>`、`?full=1`):交互式会话的读取路径 |
|
||||
| `GET` | `/api/sessions/:id/output` | 一次性的解析输出(tmux 承载的会话里 `textOutput` 为空) |
|
||||
| `GET` | `/api/sessions/:id/wait` | 阻塞到某个信号触发(`?until=stop,idle,exit&timeout=&fresh=`);超时是 `200` |
|
||||
| `GET` | `/api/sessions/:id/wait-output` | 阻塞到某个字面串出现(`?match=&nocase=&from=now\|buffer&timeout=`) |
|
||||
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
|
||||
### 重生(Respawn)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
||||
'test/opencode-resize.test.ts', // browser (Playwright)
|
||||
'test/webgl-fallback.test.ts', // browser (Playwright)
|
||||
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
|
||||
'test/codex-predictive-echo.test.ts', // browser (Playwright) + real codex binary
|
||||
],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
fileParallelism: false,
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
# Agent Control Plan: skill packaging + wait primitives
|
||||
|
||||
**Status**: steps 1 to 8 DONE and RELEASED. The wait primitives and the skill itself
|
||||
(steps 1 to 5) shipped in **1.13.0**; the `codeman skill install` CLI, per-case injection
|
||||
and `agentSkillEnabled` (step 6) shipped in **1.14.1** and were republished with fixes in
|
||||
**1.14.2**. Steps 1 to 5 were multi-round verified on 2026-08-08, step 6 on 2026-08-09;
|
||||
see [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what each
|
||||
verification round found, and the two items that genuinely remain open (§2.4's footgun
|
||||
guard and the Part 3 deferrals).
|
||||
|
||||
**Date**: 2026-08-08
|
||||
**Scope**: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built.
|
||||
Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
|
||||
|
||||
---
|
||||
|
||||
## 0. Where this came from: what herdr does
|
||||
|
||||
[herdr](https://github.com/herdrdev/herdr) (Rust, Apache-2.0, ~25.8k stars) is a terminal
|
||||
multiplexer built around AI coding agents. Relevant findings from the research pass:
|
||||
|
||||
| Capability | How herdr does it |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Agent state | Four states (`idle`, `working`, `blocked`, `done`) that roll up pane to tab to workspace in a sidebar |
|
||||
| Detection | Lifecycle hooks where the agent supports them (it names Pi and MastraCode), otherwise TOML manifests matched against a live bottom-buffer snapshot. Bundled manifests plus remote updates from herdr.dev, local overrides win |
|
||||
| Control API | Newline-delimited JSON over a Unix socket (`~/.config/herdr/sessions/<name>/herdr.sock`), `{"id":"req_1","method":"pane.split","params":{}}`, dot-notation methods, plus long-lived event subscriptions |
|
||||
| Discoverability | `herdr api schema` prints a machine-readable schema |
|
||||
| Agent skill | `npx skills add herdrdev/herdr --skill herdr -g`, a SKILL.md wrapping the CLI, guarded by `test "${HERDR_ENV:-}" = 1` so an agent outside a herdr pane refuses to act |
|
||||
| Persistence | Background server, detach with `ctrl+b q`, snapshot restore of workspaces/tabs/panes/cwd/layout, experimental screen-history replay, agent resume via native session ids, live PTY handoff across server replacement |
|
||||
| Plugins | `herdr-plugin.toml` manifest, actions, event hooks, plugin panes, link handlers, GitHub-topic marketplace index |
|
||||
|
||||
The commands the skill teaches the agent:
|
||||
|
||||
| Group | Commands |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| workspace | `workspace list`, `workspace create` |
|
||||
| tab | `tab list --workspace <id>`, `tab create` |
|
||||
| pane | `pane current`, `pane list`, `pane layout`, `pane split --current --direction right --cwd <path> --no-focus`, `pane run <id> "<cmd>"`, `pane wait-output <id> --match/--regex <p> --timeout <ms>`, `pane read <id> --source visible\|recent\|detection` |
|
||||
| agent | `agent list`, `agent start <name> --kind <type> --pane <id>`, `agent prompt <name> "<text>" --wait --timeout <ms>`, `agent wait <name> --until <state> --timeout <ms>`, `agent send-keys`, `agent get`, `agent read` |
|
||||
|
||||
### The honest comparison
|
||||
|
||||
herdr and Codeman are not the same product. herdr is a local, keyboard-first multiplexer with
|
||||
no server, no web UI, and no autonomy layer. Codeman is a server with a browser and mobile UI,
|
||||
remote and Docker cases, respawn, Ralph, cron, and the orchestrator, none of which herdr has.
|
||||
|
||||
What herdr genuinely does better is being **callable by the agent running inside it**. For
|
||||
Codeman that is a packaging problem plus one missing primitive, not an architecture problem.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gap analysis
|
||||
|
||||
| herdr capability | Codeman equivalent today | Gap |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
|
||||
| `pane split` + `agent start` | `POST /api/quick-start`, `POST /api/sessions` | none, already there |
|
||||
| `agent prompt` | `POST /api/sessions/:id/input` with `clientId`+`seq` exactly-once | no `--wait` |
|
||||
| `pane read` | `GET /api/sessions/:id/output`, `GET /api/sessions/:id/terminal?full=1` | none |
|
||||
| `agent list` / `agent get` | `GET /api/sessions`, `GET /api/sessions/unified`, `GET /api/status` | none |
|
||||
| `agent wait --until <state>` | SSE only (`/api/events`) | **missing**, and SSE is impractical from a shell tool |
|
||||
| `pane wait-output --match` | nothing | **missing** |
|
||||
| Skill file | README section "Driving Codeman from an Agent" | **not packaged**, an agent will never find it |
|
||||
| Env guard `HERDR_ENV=1` | `CODEMAN_MUX=1`, `CODEMAN_API_URL`, `CODEMAN_SESSION_ID` already exported at spawn | none, the guard variables exist |
|
||||
| `blocked` state | hook events (`permission_prompt`, `elicitation_dialog`) plus CSS classes plus the phone overview NEEDS YOU section | not in the wire contract (`SessionStatus = 'idle' \| 'busy' \| 'stopped' \| 'error'`) |
|
||||
| `api schema` | hand-written `docs/api-reference.md` | no machine-readable schema |
|
||||
| Detection manifests | hardcoded in `usage-limit-patterns.ts`, `respawn-*-patterns`, `regex-patterns.ts` | patterns are code, not data |
|
||||
| Plugin runtime | deliberately refused, see `docs/extending-codeman.md` | not a gap, a decision |
|
||||
| Session handoff on restart | tmux owns the PTYs, so they already survive a Codeman restart | not a gap, solved by architecture |
|
||||
|
||||
**Conclusion**: roughly 90% of the capability surface already exists. Parts 1 and 2 below close
|
||||
the two real gaps.
|
||||
|
||||
The table is the 2026-08-08 snapshot that motivated the work, kept as written. The three rows
|
||||
marked missing are closed since: `GET .../wait` and `GET .../wait-output` shipped in 1.13.0, and
|
||||
the skill is packaged at `skills/codeman` (npm tarball included). `blocked` as a wire-contract
|
||||
state, and the machine-readable schema, are still open (Parts 3 and 4).
|
||||
|
||||
---
|
||||
|
||||
## 2. Part 1: the Codeman agent skill
|
||||
|
||||
### 2.1 Goal
|
||||
|
||||
An agent running inside a Codeman session can discover and correctly drive Codeman without the
|
||||
user pasting API docs into the prompt, and without inventing dangerous calls.
|
||||
|
||||
### 2.2 Layout and distribution
|
||||
|
||||
The `npx skills` CLI (vercel-labs/skills) clones a GitHub repo and looks for
|
||||
`skills/<name>/SKILL.md`. Claude Code natively discovers `.claude/skills/<name>/SKILL.md` in a
|
||||
project and `~/.claude/skills/` globally. Both are satisfied with one source of truth plus a
|
||||
symlink, which is the pattern this repo already uses for `remotion-best-practices`.
|
||||
|
||||
```
|
||||
skills/
|
||||
codeman/
|
||||
SKILL.md <- single source of truth
|
||||
reference/
|
||||
endpoints.md <- full endpoint tables, loaded on demand
|
||||
recipes.md <- worked multi-session orchestration examples
|
||||
.claude/skills/codeman -> ../../skills/codeman (symlink, dogfooding in this repo)
|
||||
```
|
||||
|
||||
Adding a `skills/` directory to the repo root costs one entry in the GitHub listing. CLAUDE.md
|
||||
keeps the root short on purpose, so this needs a conscious sign-off; the alternative is
|
||||
`docs/skills/codeman/` with a `--skill` path argument, which breaks the one-liner install.
|
||||
**Recommendation**: accept `skills/` at the root, because the install one-liner is the whole
|
||||
point of shipping a skill.
|
||||
|
||||
Install paths, in order of how a user gets it:
|
||||
|
||||
1. `npx skills add Ark0N/Codeman --skill codeman -g` (global, any agent, matches the herdr flow).
|
||||
2. `codeman skill install [--global | --case <name>]`, a new CLI subcommand writing the same
|
||||
file. This is the path for users who installed via npm and never cloned the repo.
|
||||
3. **Automatic per-case injection**, modeled exactly on `applyStatusLineConfig(casePath, enabled)`
|
||||
in `hooks-config.ts`: write `<case>/.claude/skills/codeman/SKILL.md` at case creation,
|
||||
gated on a new setting. Codeman already writes `<case>/.claude/settings.local.json` hooks
|
||||
through `writeHooksConfig()`, so this is the same mechanism with the same lifecycle.
|
||||
|
||||
Setting name: `agentSkillEnabled`. Synced (not per-device), since it changes on-disk case
|
||||
content rather than display. Default: **ON after the dogfooding phase, OFF in the first
|
||||
release**. Rationale for starting OFF: Claude Code loads every skill's name and description
|
||||
into context on every turn, so an always-on skill has a small permanent token cost, and we
|
||||
should measure that we are buying something with it first.
|
||||
|
||||
### 2.3 SKILL.md content
|
||||
|
||||
Frontmatter, per the skills convention (`name` + `description` required):
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: codeman
|
||||
description: >-
|
||||
Control Codeman, the session manager this agent is running inside: list sessions,
|
||||
start worker sessions, send prompts, read terminal output, and wait for other agents
|
||||
to finish. Only usable when CODEMAN_MUX=1.
|
||||
---
|
||||
```
|
||||
|
||||
Body sections, in order:
|
||||
|
||||
**1. Guard (first thing, non-negotiable).**
|
||||
|
||||
```bash
|
||||
test "${CODEMAN_MUX:-}" = 1 || { echo "not inside a Codeman session"; exit 1; }
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set, refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:-}"
|
||||
```
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, the agent must stop and say it is not running inside a
|
||||
Codeman-managed session. Same shape as herdr's `HERDR_ENV` guard, and the variables are
|
||||
already exported by `tmux-manager.buildEnvExports()`. No fallback URL when
|
||||
`CODEMAN_API_URL` is unset: any guess is the wrong scheme on an HTTPS install (prod is
|
||||
HTTPS with a self-signed cert, hence `curl -sk` throughout), and a server the agent
|
||||
cannot identify is not one it should be driving.
|
||||
|
||||
**2. Rules of the road.** Lifted and tightened from README lines 666 to 745:
|
||||
|
||||
- Single-line input only. Multi-line breaks the agent TUI (Ink).
|
||||
- Always send `clientId` + a monotonic `seq` on `POST .../input` so a retry cannot double-deliver.
|
||||
- Envelope is `{success, data}`; a few legacy GETs are bare, so read `body.data ?? body`.
|
||||
- Add `-u admin:"$CODEMAN_PASSWORD"` when a password is set. Prod is HTTPS, so `curl -sk`.
|
||||
- Prefer `/api/v1/*`, the stable alias.
|
||||
|
||||
**3. Safety rules (the section that does not exist anywhere today).**
|
||||
|
||||
- Never act on `$CODEMAN_SESSION_ID`. That is you.
|
||||
- Only `DELETE` sessions **you created in this conversation**, by exact id. Keep the list.
|
||||
- Never bulk-delete, never loop a `DELETE` over `/api/sessions`. There is no undo.
|
||||
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. Use the API.
|
||||
- Creating a session consumes a slot against the 50-session cap. Clean up what you start.
|
||||
|
||||
**4. Recipes**, each one a single copy-pasteable curl:
|
||||
|
||||
| Task | Call |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| list sessions | `GET /api/v1/sessions` |
|
||||
| find yourself | match ids by PREFIX of `$CODEMAN_SESSION_ID` (Docker cases truncate it to 8 chars, so an equality check never fires there) |
|
||||
| start a worker | `POST /api/v1/quick-start {caseName, mode, effort}` |
|
||||
| send a prompt | `POST /api/v1/sessions/:id/input {input:"…\r", useMux:true, clientId, seq}` (the trailing `\r` is what sends Enter; without it the text sits on the prompt unsubmitted) |
|
||||
| send prompt and wait | `POST /api/v1/sessions/:id/input {input:"…\r", wait:"stop", waitTimeout:600000}` (Part 2) |
|
||||
| wait for a worker | `GET /api/v1/sessions/:id/wait?until=stop,blocked&timeout=300000` (Part 2) |
|
||||
| wait for a marker | `GET /api/v1/sessions/:id/wait-output?match=DONE_<random>&timeout=120000` (Part 2; unique per call, per §3.3's repaint rule) |
|
||||
| read output | `GET /api/v1/sessions/:id/output` |
|
||||
| read full scrollback | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| watch sub-agents | `GET /api/v1/subagents` |
|
||||
| schedule work | `POST /api/v1/cron/jobs` |
|
||||
| clean up | `DELETE /api/v1/sessions/:id` |
|
||||
|
||||
**5. Pointer to `reference/endpoints.md`** for anything not in the table, so the always-loaded
|
||||
part of the skill stays small.
|
||||
|
||||
### 2.4 An ergonomics guard worth adding server-side
|
||||
|
||||
The skill will tell the agent not to act on itself, but a confused agent can still try. Propose:
|
||||
the skill sends `X-Codeman-Caller-Session: $CODEMAN_SESSION_ID` on every request, and the server
|
||||
refuses destructive operations (`DELETE /api/sessions/:id`, kill, respawn stop) when that header
|
||||
equals the target id, with a clear error.
|
||||
|
||||
This is a **footgun guard, not a security control**: any caller can omit the header. Document it
|
||||
as such so nobody mistakes it for a boundary. It costs about 10 lines in `route-helpers.ts`.
|
||||
|
||||
### 2.5 Verification
|
||||
|
||||
Per the always-end-to-end-test rule, "the skill exists" is not done. Done is:
|
||||
|
||||
1. Symlink it into `.claude/skills/`, start a real throwaway Codeman session, and ask that agent
|
||||
to "start a worker session that runs the test suite and tell me when it finishes".
|
||||
2. Confirm from the outside that exactly one new session appeared, got the prompt, and that the
|
||||
lead agent waited rather than polling in a busy loop.
|
||||
3. Confirm the guard: run the same prompt in a shell with `CODEMAN_MUX` unset and confirm refusal.
|
||||
4. Confirm cleanup: the worker session is deleted by exact id and no other session was touched.
|
||||
|
||||
Never run this against `w1`/`w2`/`w3`.
|
||||
|
||||
### 2.6 Files touched
|
||||
|
||||
- `skills/codeman/SKILL.md` (new), `skills/codeman/reference/*.md` (new)
|
||||
- `.claude/skills/codeman` symlink (new)
|
||||
- `src/cli.ts` (new `skill install` subcommand)
|
||||
- `src/hooks-config.ts` (new `applyAgentSkill(casePath, enabled)`, mirroring `applyStatusLineConfig`)
|
||||
- `src/web/schemas.ts` (`agentSkillEnabled` in `SettingsUpdateSchema`, which is `.strict()`)
|
||||
- `src/web/routes/system-routes.ts` (settings PUT must resolve the flag from `merged`, never
|
||||
from the raw body, per the partial-PUT invariant)
|
||||
- `src/web/public/settings-ui.js` + `index.html` (checkbox)
|
||||
- `package.json` `files` array, so `skills/` ships to npm
|
||||
- README pointer, `docs/extending-codeman.md` seam 3 pointer
|
||||
|
||||
---
|
||||
|
||||
## 3. Part 2: wait primitives
|
||||
|
||||
### 3.1 Goal
|
||||
|
||||
Make Codeman orchestratable from a shell tool. Today the only "tell me when" channel is SSE,
|
||||
which a curl-driven agent cannot practically consume: it would have to hold a streaming
|
||||
connection and parse events inline. herdr solves this with blocking CLI calls. Codeman should
|
||||
solve it with bounded long-poll endpoints.
|
||||
|
||||
All three additions are **additive**, so the versioning policy stays intact (new endpoints and
|
||||
new optional fields are non-breaking).
|
||||
|
||||
### 3.2 The signal model
|
||||
|
||||
A waiter resolves on the first of a set of signals. Sources that already exist:
|
||||
|
||||
| Signal | Source today |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `idle` | `Session` emits `idle` (session.ts ~1775 for Claude, ~2101 for shell), wired at `session-listener-wiring.ts:402` |
|
||||
| `working` | `Session` emits `working` (session.ts ~1788), wired at `session-listener-wiring.ts:401` |
|
||||
| `stop` | `POST /api/hook-event` with `event: 'stop'`, the definitive "Claude finished responding" signal already used by `controller.signalStopHook()` |
|
||||
| `blocked` | `POST /api/hook-event` with `permission_prompt` or `elicitation_dialog` |
|
||||
| `exit` | `Session` emits `exit` |
|
||||
|
||||
`stop` is the highest-quality signal for "the turn is over" and should be the documented default
|
||||
for orchestration. `idle` is heuristic: output stabilization plus prompt detection, and it can
|
||||
flap mid-turn when a spinner pauses. External CLI modes (`isExternalCliMode()`) have no stop
|
||||
hook at all, so for opencode/codex/gemini/antigravity only `idle`, `working` and `exit` are
|
||||
available. **The skill and the docs must say which signals exist per mode**, otherwise an agent
|
||||
waits forever on `stop` in a codex session.
|
||||
|
||||
### 3.3 Endpoint specs
|
||||
|
||||
#### A. `GET /api/sessions/:id/wait`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
| --------- | ---------------------------------------------- | ---------------- | ------------------------------------------------------------ |
|
||||
| `until` | comma list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on first match |
|
||||
| `timeout` | ms | 60000 | clamped to `MAX_WAIT_MS` (600000) |
|
||||
| `fresh` | `0`/`1` | `0` | `1` requires a _transition_, ignoring the state at call time |
|
||||
|
||||
Response (always 200 unless the session is missing or a cap is hit):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"signal": "stop",
|
||||
"timedOut": false,
|
||||
"immediate": false,
|
||||
"ended": false,
|
||||
"waitedMs": 8421,
|
||||
"status": "idle",
|
||||
"sessionId": "...",
|
||||
"until": ["stop", "idle", "exit"],
|
||||
"limitPaused": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`until` is echoed back because the server may narrow it: `stop`/`blocked` are dropped
|
||||
from the DEFAULT set for external CLI modes (asking for them EXPLICITLY is a 400
|
||||
instead, since omitting `until` must never 400). `limitPaused` tells a caller that a
|
||||
timeout was expected rather than a stall worth retrying hard.
|
||||
|
||||
**A timeout is not an error.** `{"timedOut": true, "signal": null}` with HTTP 200, so a caller
|
||||
can loop without treating every poll boundary as a failure. Errors are reserved for
|
||||
`NOT_FOUND` (unknown or not-owned session) and `SESSION_BUSY` (waiter cap exceeded).
|
||||
|
||||
`immediate: true` means the session was already in the requested state and `fresh` was not set.
|
||||
|
||||
#### B. `GET /api/sessions/:id/wait-output`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
| --------- | ------------------------------ | -------- | --------------------------------------------------------- |
|
||||
| `match` | literal string, 1 to 200 chars | required | substring match against ANSI-stripped output |
|
||||
| `nocase` | `0`/`1` | `0` | case-insensitive compare |
|
||||
| `from` | `now` \| `buffer` | `now` | `buffer` scans the existing text buffer first, then waits |
|
||||
| `timeout` | ms | 60000 | clamped to `MAX_WAIT_MS` |
|
||||
|
||||
Response: `{ matched: true, timedOut: false, snippet: "...", waitedMs }`.
|
||||
|
||||
**No regex in v1, deliberately.** `search-service.ts` already avoids regex specifically so there
|
||||
is no ReDoS surface, and this endpoint would be even more exposed since the pattern is attacker
|
||||
supplied and the input is a live stream. herdr can offer `--regex` because Rust's regex crate is
|
||||
linear-time with no backtracking; JS `RegExp` is not. If regex is wanted later, the honest
|
||||
options are a length-capped subset compiled once with a match budget, or `re2`. Note it and move on.
|
||||
|
||||
Implementation detail that will bite if missed: a match can straddle two PTY chunks. Keep a
|
||||
carry buffer of `match.length - 1` bytes from the previous chunk and test `carry + chunk`.
|
||||
|
||||
⚠️ **`from=now` does not mean "printed after you asked".** tmux repaints the visible
|
||||
screen on attach, resize, or any TUI redraw, and a repaint arrives as ordinary `terminal`
|
||||
data. Observed live: a marker echoed a minute earlier matched instantly on a fresh
|
||||
`from=now` wait. This is inherent to a terminal multiplexer, not fixable in the registry,
|
||||
so the contract is: **use a marker unique per call** (`echo DONE_$RANDOM`), never a
|
||||
generic one like `BUILD OK`. The skill's recipes must show that.
|
||||
|
||||
The returned snippet is whitespace-collapsed (blank runs to a single newline) for
|
||||
readability only; matching runs on the raw stripped text. Without it, a real pane's
|
||||
`\r\n` padding between the prompt and the match fills the whole context window with
|
||||
nothing, which was the first thing the live test showed.
|
||||
|
||||
#### C. `wait` on the existing input endpoint
|
||||
|
||||
`POST /api/sessions/:id/input` gains two optional fields:
|
||||
|
||||
```json
|
||||
{ "input": "run the tests\r", "useMux": true, "clientId": "agent-1", "seq": 7, "wait": "stop", "waitTimeout": 600000 }
|
||||
```
|
||||
|
||||
(The trailing `\r` is required on every input body: `sendInput` sends Enter only
|
||||
when the input contains a carriage return.)
|
||||
|
||||
Response gains `"wait": { "signal": "stop", "timedOut": false, "waitedMs": 41230 }`.
|
||||
|
||||
This is the important one, because it closes a race the standalone `GET .../wait` cannot: between
|
||||
"input delivered" and "session flips to working" there is a window where a naive
|
||||
send-then-wait sees the _pre-existing_ idle state and returns instantly. The combined endpoint
|
||||
**registers the waiter before writing**, so that window does not exist. This is exactly why herdr
|
||||
ships `agent prompt --wait` as its own thing.
|
||||
|
||||
`wait` accepts `true` (the default signal set) or the same comma grammar as `until`.
|
||||
Both new fields are `.nullish()`, not `.optional()`: a third-party caller building the
|
||||
body with `JSON.stringify` keeps an explicit `null` on the wire, and `.optional()`
|
||||
rejects that with `INVALID_INPUT`. That gotcha has shipped as a real bug twice.
|
||||
|
||||
Two behaviors to preserve carefully:
|
||||
|
||||
- **`useMux` is fire-and-forget today.** The handler responds without awaiting `writeViaMux`, on
|
||||
purpose (a tmux child process must not block the HTTP response). With `wait` present the
|
||||
handler already has to stay open, so it can await delivery, and a `writeViaMux` failure becomes
|
||||
observable for the first time. The non-wait path must keep its current fire-and-forget shape
|
||||
byte for byte.
|
||||
- **Duplicate suppression.** A tagged redelivery (`clientId`+`seq` already applied) returns 200
|
||||
without writing. With `wait` set it still waits, since the caller's intent is "tell me when
|
||||
this settles". But it waits with `requireTransition: false`, unlike a fresh delivery: the
|
||||
original turn may be long over, and requiring a new transition would block a redelivery until
|
||||
timeout for no reason. Fresh delivery requires a transition, a duplicate answers from the
|
||||
current state.
|
||||
- **Capacity rollback.** `shouldApplyInput()` MUTATES (it records the seq), and it runs before
|
||||
the waiter is registered. If registration then fails on a full pool, the handler must call
|
||||
`forgetInputSeq` before returning `SESSION_BUSY`, or the caller's retry is rejected as a
|
||||
duplicate and the input is lost by the very mechanism reliable delivery exists for.
|
||||
|
||||
### 3.4 Module design
|
||||
|
||||
New file `src/web/session-wait-registry.ts`, with the IO-free core unit-testable in isolation
|
||||
(same split as `self-update.ts`):
|
||||
|
||||
```ts
|
||||
type WaitSignal = 'idle' | 'working' | 'stop' | 'blocked' | 'exit';
|
||||
|
||||
waitForSignal(sessionId, { until: Set<WaitSignal>, timeoutMs, requireTransition }): Promise<WaitResult>
|
||||
notifySignal(sessionId, signal: WaitSignal): void
|
||||
waitForOutput(sessionId, { match, nocase, timeoutMs }): Promise<OutputWaitResult>
|
||||
notifyOutput(sessionId, chunk: string): void
|
||||
cancelAll(sessionId, reason): void
|
||||
```
|
||||
|
||||
Wiring points, all existing:
|
||||
|
||||
- `src/web/session-listener-wiring.ts` around lines 190 and 200 already handles `working` and
|
||||
`idle` and broadcasts them. Add a `notifySignal()` call next to each broadcast, plus `exit`.
|
||||
- `src/web/routes/hook-event-routes.ts` already switches on `event` for the respawn controller.
|
||||
Add `notifySignal(sessionId, 'stop' | 'blocked')` in the same switch.
|
||||
- Output: `notifyOutput()` rides the ALREADY-attached `terminal` listener in
|
||||
session-listener-wiring.ts. An earlier draft had the registry hand out attach/detach
|
||||
callbacks so a listener could be added lazily; that was deleted once it was clear no
|
||||
second listener is needed at all. The cost is one Map lookup per PTY chunk, which is why
|
||||
the no-waiter check comes before the ANSI strip.
|
||||
- Session deletion calls `notifySignal('exit')` then `cancelAll()`, so no promise is left
|
||||
hanging. Both are required: `_doCleanupSession` detaches the session's listeners BEFORE
|
||||
`session.stop()`, so on a delete the PTY exit event never reaches the registry, and an
|
||||
`until=exit` caller would otherwise get a bare `ended` instead of its signal. Found by
|
||||
live-testing the delete path, not by the unit tests.
|
||||
|
||||
Memory-leak discipline, per the 24-hour-session rules: every waiter owns a timer that is cleared
|
||||
on resolve, the per-session waiter set is deleted when it empties, and the output listener is
|
||||
removed with it. `test/memory-leak-prevention.test.ts` should grow a case for this.
|
||||
|
||||
Caps in a new `src/config/agent-wait.ts` (limits live in `src/config/`, env-overridable):
|
||||
|
||||
| Constant | Default | Why |
|
||||
| ------------------------- | ------- | --------------------------------------- |
|
||||
| `MAX_WAIT_MS` | 600000 | an unbounded long-poll is a socket leak |
|
||||
| `DEFAULT_WAIT_MS` | 60000 | short enough to survive most proxies |
|
||||
| `MAX_WAITERS_PER_SESSION` | 16 | |
|
||||
| `MAX_WAITERS_TOTAL` | 128 | same reasoning as `MAX_SSE_CLIENTS` |
|
||||
|
||||
Exceeding a cap returns `SESSION_BUSY`, not a silent queue.
|
||||
|
||||
### 3.5 Transport concerns
|
||||
|
||||
Fastify is constructed with defaults in `server.ts:329-331`. `requestTimeout` defaults to 0
|
||||
(disabled) and `keepAliveTimeout` (72s) applies between requests, not to an in-flight one, so a
|
||||
10-minute in-process hold is fine. **Verify this on the real instance before relying on it.**
|
||||
|
||||
Intermediaries are the actual risk. Prod is reached through `tailscale serve`, and users also run
|
||||
cloudflared tunnels; both can cut an idle connection. That is why `DEFAULT_WAIT_MS` is 60s and
|
||||
why the documented pattern is a client-side loop over short waits rather than one 10-minute call.
|
||||
The skill's recipes must show the loop.
|
||||
|
||||
### 3.6 Edge cases to get right
|
||||
|
||||
| Case | Behavior |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Session already idle, `fresh=0` | return immediately, `immediate: true` |
|
||||
| Session already idle, `fresh=1` | wait for the next transition into a requested state |
|
||||
| Session dies mid-wait | resolve with `signal: "exit"` if `exit` was requested, otherwise resolve `timedOut:false, signal:null, ended:true`. Never hang |
|
||||
| Session deleted mid-wait | same, resolve, do not throw. Verified live: `until=exit` gets `signal:"exit"`, a concurrent `until=blocked` gets `ended:true`, both in ~0ms |
|
||||
| Shutdown with a wait pending | `cancelEverything()` in `stop()`. Verified live: SIGTERM with a 300s wait in flight exits in 1s |
|
||||
| External CLI mode | `stop` and `blocked` never fire. Reject `until=stop` for those modes with a clear `INVALID_INPUT` rather than hanging until timeout |
|
||||
| Multi-user | goes through `findSessionOrFail(ctx, id, req)`, which already enforces ownership |
|
||||
| Remote / Docker cases | signals originate from the same `Session` object, so no special casing. Docker hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for `stop`/`blocked` to arrive at all; without it, only `idle` works. Document it |
|
||||
| Respawn `/clear` mid-wait | a respawn cycle emits `idle`. Callers waiting on `stop` are unaffected; callers on `idle` may resolve early. Documented, not fixed |
|
||||
| Limit pause | if the session is paused on a usage limit, nothing will fire until the reset. The wait times out honestly. Consider surfacing `limitPaused: true` in the response so the caller can back off |
|
||||
|
||||
### 3.7 Tests
|
||||
|
||||
- `test/session-wait-registry.test.ts` (pure): immediate resolve, transition-required, multi-signal
|
||||
first-wins, timeout, cap exceeded, cancel on session end, no listener leak after resolve,
|
||||
chunk-straddling output match, case-insensitive match.
|
||||
- `test/routes/session-wait-routes.test.ts` (`app.inject()`, no port): all three endpoints against
|
||||
a `MockSession`, including the 200-with-`timedOut` contract and the ownership 404.
|
||||
- `test/routes/session-input-wait.test.ts`: the send-and-wait race, plus proof that the non-wait
|
||||
path is unchanged (still returns before `writeViaMux` settles).
|
||||
- Live verification on a throwaway session before COM, per the always-end-to-end-test rule.
|
||||
|
||||
### 3.8 Files touched
|
||||
|
||||
- `src/config/agent-wait.ts` (new)
|
||||
- `src/web/session-wait-registry.ts` (new)
|
||||
- `src/web/session-listener-wiring.ts` (notify on idle/working/exit)
|
||||
- `src/web/routes/hook-event-routes.ts` (notify on stop/blocked)
|
||||
- `src/web/routes/session-routes.ts` (two new routes, `wait` fields on input)
|
||||
- `src/web/schemas.ts` (`SessionWaitQuerySchema`, `SessionWaitOutputQuerySchema`, extend
|
||||
`SessionInputWithLimitSchema`. Note: `.optional()` rejects `null`, so the frontend and any
|
||||
generated client must send `undefined`, never `null`)
|
||||
- `docs/api-reference.md`, `docs/extending-codeman.md`, README API table
|
||||
- `skills/codeman/SKILL.md` recipes (Part 1 depends on this)
|
||||
|
||||
---
|
||||
|
||||
## 4. Deferred: parts 3 to 5
|
||||
|
||||
Not in scope now, kept here so they are not lost.
|
||||
|
||||
### Part 3: promote `blocked` to a first-class state
|
||||
|
||||
`SessionStatus` is `'idle' | 'busy' | 'stopped' | 'error'`. "Needs you" exists three times over:
|
||||
hook events, the `tab-alert-action` CSS class, and the phone overview NEEDS YOU section, each
|
||||
re-deriving it. herdr makes `blocked` a real state that rolls up.
|
||||
|
||||
Add `blocked` (and possibly `done`) to `SessionStatus`, set it from the same hook events that
|
||||
Part 2 uses as wait signals, and clear it on the next `working`/`stop`. Then the tab strip, the
|
||||
mobile overview, the wait endpoints, and any external agent read one field.
|
||||
|
||||
Cost: `SessionStatus` is a widely-consumed union, so every exhaustive `switch` (the codebase has
|
||||
`assertNever` and `noFallthroughCasesInSwitch`) will need a branch. That is a feature, it makes
|
||||
the compiler find every site. This is a **minor** bump, not a patch: it widens a public type in
|
||||
the HTTP contract.
|
||||
|
||||
### Part 4: `GET /api/schema`
|
||||
|
||||
herdr ships `herdr api schema`. Every Codeman route is already Zod-validated, so
|
||||
`zod-to-json-schema` over `schemas.ts` gives a self-describing API almost free. Value: third-party
|
||||
tools and the skill stop drifting from hand-written docs. Open question: whether to emit full
|
||||
OpenAPI (`@fastify/swagger` would need per-route schema registration, which is a much larger
|
||||
change) or just dump the Zod schemas keyed by name (cheap, 80% of the value).
|
||||
|
||||
### Part 5: detection manifests instead of hardcoded patterns
|
||||
|
||||
CLI-specific readiness, blocked and usage-limit patterns live in code across
|
||||
`usage-limit-patterns.ts`, the respawn pattern helpers and `regex-patterns.ts`. Externalizing the
|
||||
per-CLI ones into data files would make adding a sixth CLI a data change instead of a code change.
|
||||
|
||||
**Do not copy the remote-update part.** herdr auto-fetches manifest updates from herdr.dev.
|
||||
Codeman auto-pulling behavioral rules from a vendor server contradicts its security posture.
|
||||
Bundled manifests plus local override only, no network.
|
||||
|
||||
### Explicit non-goals
|
||||
|
||||
- **Plugin runtime and marketplace.** `docs/extending-codeman.md` already argues this: a plugin
|
||||
runtime means third-party code inside a process that spawns agents with your credentials, on a
|
||||
server people expose over a tunnel. The reasoning still holds. If the marketplace _pattern_ is
|
||||
wanted, apply it to data (web tabs, case templates, cron recipes), never to executable code.
|
||||
- **Live PTY handoff on restart.** herdr needs it because it owns the terminals. Codeman
|
||||
delegates to tmux, so PTYs already survive a self-update restart.
|
||||
- **Socket API.** HTTP plus SSE is the existing, documented, stable contract. A second transport
|
||||
would double the surface for no capability gain.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sequencing
|
||||
|
||||
| Step | Work | Gate |
|
||||
| ---- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 ✅ | `src/config/agent-wait.ts` + `session-wait-registry.ts` + unit tests | 48 tests green |
|
||||
| 2 ✅ | `GET .../wait` + wiring in listener-wiring, hook-event-routes, server teardown | 15 route tests green; live-verified on an isolated `CODEMAN_INSTANCE=waittest` instance (immediate resolve, 400 on a bad signal, 200+`timedOut` on timeout, hook `stop` and `permission_prompt`→`blocked` waking an in-flight wait, delete delivering `exit`, SIGTERM not blocked); full `test:ci` sweep green |
|
||||
| 3 ✅ | `GET .../wait-output` | 16 route tests green; live-verified on real PTY bytes (`echo MARKER` waking a blocked request in ~1s, `from=buffer` immediate hit, never-seen marker timing out at exactly 2001ms, nocase, `regex` refused with a 400); full `test:ci` sweep green |
|
||||
| 4 ✅ | `wait` field on `POST .../input`, non-wait path proven unchanged | 16 route tests green; live-verified (no-wait returns in 26ms with the historical bare body; an idle session did NOT satisfy a `wait` request, blocking the full 2001ms, which is the race the endpoint exists to close; the stop hook resolved a send-and-wait at 1510ms and the input was confirmed in the tmux pane; `wait:null` accepted) |
|
||||
| 5 ✅ | `skills/codeman/SKILL.md` + reference files + `.claude/skills` symlink | live dogfood: a real session orchestrates a worker end to end |
|
||||
| 6 ✅ | `codeman skill install` CLI + `applyAgentSkill()` + `agentSkillEnabled` setting | 10 unit tests (`test/agent-skill.test.ts`) + real-server case-creation tests (`test/quick-start.test.ts`, incl. the settings PUT accepting the key) green; CLI verified live (install/uninstall, global + `--case`, foreign/symlink refusals) |
|
||||
| 7 ✅ | Docs: api-reference, extending-codeman, README | plus `architecture-invariants.md` (§agent-wait-primitives), `CLAUDE.md` and the API reference's per-mode signal table |
|
||||
| 8 ✅ | COM (minor bump: new endpoints, new setting, new optional fields) | released as 1.13.0 (wait primitives + skill); step 6 followed in 1.14.1 and was republished as 1.14.2 after live-testing the packaged skill |
|
||||
|
||||
Parts 1 and 2 are independent enough to land separately, but the skill is much less useful
|
||||
without the wait endpoints, so the wait work goes first.
|
||||
|
||||
## 6. Open questions for the owner
|
||||
|
||||
1. ✅ `skills/` at the repo root: accepted (built that way; the install one-liner depends on it).
|
||||
2. ✅ `agentSkillEnabled` default: **OFF** for the first release, per §2.2's rationale (skills
|
||||
cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it.
|
||||
3. ✅ Both: global install via `npx skills add` / `codeman skill install`, AND per-case
|
||||
auto-injection behind the (default-off) setting. Injection is add-only at session create and
|
||||
marker-guarded, so a user-authored copy is never touched.
|
||||
4. Is `X-Codeman-Caller-Session` self-protection worth the 10 lines, given it is a footgun guard
|
||||
and not a security boundary? (Still open, not built with step 6.)
|
||||
5. ✅ Regex support in `wait-output`: literal-only shipped, and a `regex` query param is
|
||||
rejected with a 400 rather than ignored, so an agent that assumed otherwise cannot
|
||||
silently wait on the wrong thing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Build log: what actually happened
|
||||
|
||||
Written at the end of the build so the next person inherits the reasoning, not just the
|
||||
diff. Process artifacts (per-agent briefs, findings, reports) live in the gitignored
|
||||
`tmp/agent-wait-review/`; this section is the part worth keeping.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Piece | Files |
|
||||
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Bounds + clamping | `src/config/agent-wait.ts` (new) |
|
||||
| Blocking-wait registry | `src/web/session-wait-registry.ts` (new, IO-free, unit-tested) |
|
||||
| `GET .../wait`, `GET .../wait-output`, `wait`/`waitTimeout` on `POST .../input` | `src/web/routes/session-routes.ts` |
|
||||
| Signal wiring | `session-listener-wiring.ts` (idle/working/exit + output), `hook-event-routes.ts` (stop/blocked), `server.ts` (teardown, shutdown) |
|
||||
| Agent skill | `skills/codeman/SKILL.md` + `reference/`, `.claude/skills/codeman` symlink, `package.json` `files` |
|
||||
| Docs | `api-reference.md`, `extending-codeman.md`, `architecture-invariants.md`, `README.md`, `CLAUDE.md` |
|
||||
| Tests | `test/session-wait-registry.test.ts`, three `test/routes/session-*wait*.test.ts`, `http-contract.test.ts`, `mock-session.ts` |
|
||||
|
||||
### Bugs found in ADJACENT code, not in the new feature
|
||||
|
||||
These are the highest-value output of the exercise and none were on the plan:
|
||||
|
||||
1. **Every Codeman hook was dead on HTTPS installs.** `hooks-config.ts` built the hook
|
||||
curl as `curl -s` with no `-k` while the statusline exporter 300 lines below used
|
||||
`curl -sk` and documented why. Proven with the real hook command: `curl exit=60`
|
||||
without the flag, success with it, and the failure swallowed by the hook's own
|
||||
`2>/dev/null || true`. This silently killed `stop`, `permission_prompt`,
|
||||
`elicitation_dialog`, `idle_prompt`, `teammate_idle` and `task_completed`, taking
|
||||
respawn's definitive idle signals with them. Fixed, **plus** a staleness detector in
|
||||
`refreshStaleCodemanHooks` that regenerates the on-disk config of already-created
|
||||
cases (23 of 26 local cases carried the broken form; fixing the generator alone would
|
||||
have left every one of them broken).
|
||||
2. **`buildEnvExports()` exported a wrong-scheme `CODEMAN_API_URL`** (`http://` fallback
|
||||
on an HTTPS install). Now omitted rather than guessed, so in-session guards fail closed.
|
||||
3. **Programmatic input is only submitted when it contains `\r`.** `sendInput` sends Enter
|
||||
only if the payload has a carriage return; without it the text sits in the composer
|
||||
forever. Bit this build repeatedly before it was diagnosed, and had leaked into the
|
||||
docs' own examples.
|
||||
|
||||
### Design decisions worth not re-litigating
|
||||
|
||||
- **A timeout is HTTP 200** with `wait.timedOut`, never a 4xx: callers loop over short
|
||||
waits because tunnels cut idle connections, and every poll boundary would otherwise be
|
||||
indistinguishable from failure.
|
||||
- **Send-and-wait must be one endpoint.** A separate POST-then-wait races: between the
|
||||
write and the flip to `working`, a wait sees the stale `idle` and reports the PREVIOUS
|
||||
turn as this one. The waiter is registered before the write.
|
||||
- **`stop`/`blocked` exist for `claude` mode only.** They come from Claude Code hooks;
|
||||
`shell` installs none either, so keying off `isExternalCliMode()` was wrong.
|
||||
- **Literal matching only, never regex.** JS `RegExp` backtracks; herdr can offer
|
||||
`--regex` because Rust's regex crate is linear-time.
|
||||
- **Client-hangup abort listens on `reply.raw` guarded by `writableFinished`.** On
|
||||
`req.raw`, `close` fires when the request BODY ends, which on a POST killed every
|
||||
send-and-wait instantly, and no `app.inject()` test can see it (inject never emits
|
||||
`close`).
|
||||
- **Liveness cannot come from `session.pid`.** For a tmux session that is the local
|
||||
`tmux attach` client, not the worker: a worker exiting inside its pane leaves
|
||||
`pane_dead=1` with the client alive, so `pid` never goes null. Liveness is probed at
|
||||
the mux layer, cached (~750 ms) and only on blocking waits, never on the input hot path.
|
||||
|
||||
### Verification rounds
|
||||
|
||||
Six agents across three rounds, each verifying the previous round's work rather than its
|
||||
own. Findings that mattered, in order of severity, were: the dead-pane liveness gap; the
|
||||
`reply.raw` abort regression; abandoned long-polls leaking waiter slots; a crashed session
|
||||
reporting `idle`; `shell` accepting `until=stop`; and a documented recipe that reported
|
||||
success without running its task. Two traps recurred often enough to name:
|
||||
|
||||
- **Vacuous passes.** `app.inject()` never emits `close`; a latched `cancelEverything()`
|
||||
in `afterEach` silently killed the registry for every later test in a file; three test
|
||||
files sharing one session id against the process-wide registry let one file's leftover
|
||||
waiter fail another's assertion. Any new wait test needs care on all three.
|
||||
- **HTTP-only test instances.** Every isolated instance used during the build was plain
|
||||
HTTP, which is exactly why the HTTPS hook bug survived so long. Test the transport the
|
||||
user actually runs.
|
||||
|
||||
### Resolved at wrap-up (2026-08-08, conclusion pass)
|
||||
|
||||
- **R2-A**: the fire-and-forget-then-gather-sequentially pattern was **removed from
|
||||
the skill** rather than patched. Signals are edge-triggered with no history, so a
|
||||
`stop` that fires before its waiter registers is unobservable afterwards; a
|
||||
`fresh=0` gather was rejected because the only `until` set that current state can
|
||||
satisfy answers `idle` for a prompt that never submitted, resurrecting the exact
|
||||
false-success failure R2-B had just closed. Flow 3b's pattern B now gathers on
|
||||
latched `wait-output` markers (`from=buffer`), the same mechanism that makes the
|
||||
shell flows reliable; the limitation is recorded in
|
||||
`architecture-invariants#agent-wait-primitives` and `endpoints.md`. The durable
|
||||
fix, a latched last-signal-per-turn on the server, stays with deferred Part 3.
|
||||
- Docs F7/F8, F4 and the false-`idle` attribution: `api-reference.md`,
|
||||
`extending-codeman.md` and `architecture-invariants.md` rewritten to the post-fix
|
||||
matcher (one normalized stream, chunk-straddling found, snippet as a rendering of
|
||||
the matched window), the real no-PTY answer (`ended:true`, `aborted:false`,
|
||||
`delivered:false`), and the startup-idle mechanism (a session parked on the trust
|
||||
dialog emits no further `idle`; the false success is the startup transition).
|
||||
- Orchestrate #12, #5/R2-B, #6, and R2-C..R2-E: fire-and-forget's empty `data`
|
||||
documented; every send-and-wait retry loop now treats `duplicate:true` +
|
||||
`immediate:true` as "no new turn ran" and reads the terminal before believing it;
|
||||
claude fan-out is pattern A (backgrounded send-and-waits) or the marker gather;
|
||||
readiness budgets rebalanced (5 s stage 1, 45 s stage 3) with the virgin-case
|
||||
floor named; the auth fallback now also reads the supervisor definition
|
||||
(`codeman-web.service` / launchd plist) and accepts `export`-prefixed `.env`
|
||||
lines; `pid != null` is documented as startup-only, never liveness.
|
||||
- Both public readiness recipes (extending-codeman.md, README) are bypass-first with
|
||||
the trust probe as the bounded fallback; the worked recipe carries `-k` and fails
|
||||
loudly on an empty SID; the hook `-k`/self-heal fix appears in every
|
||||
"hooks go missing" list; the multi-word-TUI claim is "unreliable", not "never".
|
||||
|
||||
### Still open
|
||||
|
||||
Both release-checklist items that used to sit here are done: `skills/` is tracked and
|
||||
ships through `package.json` `files` (published with 1.13.0, republished with 1.14.2),
|
||||
and the changeset was consumed, committed and deployed. What is left:
|
||||
|
||||
- Deferred with Part 3: the latched last-signal-per-turn. Nice-to-haves from the
|
||||
reviews: N2 (create the death-watcher inside its `try`, still built one line above
|
||||
it in `GET .../wait`) and converting timeout-shaped test detections into fast
|
||||
assertions.
|
||||
- §2.4's `X-Codeman-Caller-Session` footgun guard: still not built (open question 4).
|
||||
|
||||
### Step 6 (2026-08-09): install command, per-case injection, the setting
|
||||
|
||||
Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
|
||||
|
||||
| Piece | Where |
|
||||
| ----- | ----- |
|
||||
| `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` |
|
||||
| `codeman skill install` / `skill uninstall` (`--global` default, `--case <name>`) | `src/cli.ts` |
|
||||
| `agentSkillEnabled` (SYNCED, default OFF) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` |
|
||||
| Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
|
||||
| Tests | `test/agent-skill.test.ts` (10 unit), `test/quick-start.test.ts` (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
|
||||
|
||||
Decisions worth keeping:
|
||||
|
||||
- **Ownership marker, prefix-matched.** The injected SKILL.md ends with
|
||||
`<!-- codeman-managed-agent-skill: … -->`; install/refresh/remove all refuse a copy
|
||||
without the marker (a user's own skill) and match on the PREFIX so a wording change
|
||||
cannot disown older injected copies (the `BACKGROUND_WAKE_MARKER_PREFIX` pattern).
|
||||
- **Symlink refusal.** This repo's own dogfooding layout
|
||||
(`.claude/skills/codeman -> ../../skills/codeman`) means the injector must `lstat`
|
||||
the skill dir AND its `skills/` parent and bail on a symlink, or enabling the
|
||||
setting in the Codeman repo itself would overwrite the skill source through the link.
|
||||
- **ADD-ONLY at session create**, same shared-`.claude` rationale as the statusLine:
|
||||
a create while the setting is off must not yank the skill out from under other live
|
||||
sessions in the repo. The remove path exists (CLI `skill uninstall`, tests); no
|
||||
automatic sweep removes on toggle-off.
|
||||
- **Removal is manifest-based, never `rm -rf`**: only files the packaged source would
|
||||
have written are deleted, directories are pruned bottom-up only if they emptied, so
|
||||
a user's extra notes in `reference/` survive an uninstall.
|
||||
- **Source resolution**: `join(moduleDir, '..', 'skills', 'codeman')` works from
|
||||
`src/` (tsx), `dist/` (tsc build), and the npm tarball alike, because all three sit
|
||||
one level below the package root and `files` ships `skills/`.
|
||||
- **Nothing acts on the setting at PUT time**: injection reads the merged persisted
|
||||
settings at session create (`readSettings`, ~2s cache), so the partial-PUT invariant
|
||||
(`toggleService` reading `merged`) is untouched by construction.
|
||||
@@ -46,6 +46,20 @@ payload return `{ "success": true, "data": {} }`.
|
||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||
|
||||
> 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
|
||||
> to 600 s. Proxy operators and HTTP clients with a global read timeout need to know
|
||||
> that before pointing them at Codeman.
|
||||
|
||||
⚠️ **A `401` is the one status that is not an envelope.** Authentication is rejected
|
||||
in a request hook, before any handler runs, and it replies with the bare string
|
||||
`Unauthorized` (`Unauthorized: hook secret required` on the hook path) plus
|
||||
`WWW-Authenticate: Basic realm="Codeman"`. There is no `success`, no `error`, and no
|
||||
`errorCode`, because the wrapping hook only wraps object payloads. So a client that
|
||||
pipes every response straight into a JSON parser dies with a parse error rather than
|
||||
reporting an auth failure, which is a confusing way to discover that a password is
|
||||
set. Branch on the HTTP status **before** parsing.
|
||||
|
||||
## Error codes → HTTP status
|
||||
|
||||
The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in
|
||||
@@ -66,6 +80,333 @@ the HTTP status.
|
||||
|
||||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||||
|
||||
## Long-polling (agent wait)
|
||||
|
||||
Three calls block until something happens instead of answering immediately. They
|
||||
exist because SSE is Codeman's only other "tell me when" channel, and an agent
|
||||
driving the API from a shell tool cannot practically hold a stream and parse
|
||||
events inline.
|
||||
|
||||
| Call | Blocks until |
|
||||
|------|--------------|
|
||||
| `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires |
|
||||
| `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output |
|
||||
| `POST /api/v1/sessions/:id/input` with `wait` | the input is delivered **and then** a signal fires |
|
||||
|
||||
`POST .../input` with `wait` is not the same as a `POST` followed by a separate
|
||||
`GET .../wait`. It registers the waiter **before** writing, which closes the window
|
||||
in which a separate wait sees the session still idle from the previous turn and
|
||||
answers instantly with the wrong turn's result. Use it whenever you send a prompt
|
||||
and want to know when that prompt is done.
|
||||
|
||||
### Three semantics that break callers who assume otherwise
|
||||
|
||||
**1. A timeout is HTTP `200`, not an error.** A wait that ends without its signal
|
||||
returns `{"success":true, ...,"wait":{"timedOut":true,"signal":null}}`. The
|
||||
intended pattern is a client-side loop over short waits, because `tailscale serve`
|
||||
and cloudflared can both cut an idle connection, and turning every poll boundary
|
||||
into a `4xx` would make that loop indistinguishable from a real failure. `408` is
|
||||
auto-retried by several clients (silently doubling the polling load), `504` is what
|
||||
a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status` /
|
||||
`limitPaused`. Reserve error handling for the four codes in the table below.
|
||||
|
||||
**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude
|
||||
Code hooks, and no other mode installs them: `shell` runs no agent, and the external
|
||||
CLIs (`opencode`, `codex`, `gemini`, `antigravity`) render their own TUIs and post
|
||||
no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are
|
||||
accepted, and of those only `exit` is dependable: see the caveats under
|
||||
[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked`
|
||||
**explicitly** on such a session is a
|
||||
`400`; omitting `until` never fails, the server just drops them from the default set
|
||||
and echoes the narrowed set back as `wait.until`. Three more places hooks can go
|
||||
missing even in `claude` mode: a **Docker case** needs
|
||||
`CODEMAN_DOCKER_BRIDGE_HOOKS=1`, since a container cannot reach a loopback-bound
|
||||
Codeman (without it, only `idle` / `working` / `exit` work); a **remote-SSH
|
||||
case** runs the agent on another host, whose hooks may never reach this server at
|
||||
all; and a case whose hook config was written by **Codeman < 1.13.0 against an
|
||||
`--https` install** carries hook curls without `-k`, which TLS-fail silently (the
|
||||
hook line ends in `|| true`). Codeman now writes `curl -sk` and repairs a stale
|
||||
case config the next time a session starts in that case. When in doubt, ask for
|
||||
`stop,idle,exit` so a session without hooks still resolves on the heuristic
|
||||
signal.
|
||||
|
||||
**3. `from=now` does not mean "printed after you asked".** tmux repaints the visible
|
||||
screen on attach, on resize, and on any TUI redraw, and a repaint arrives as
|
||||
ordinary output, so text that was already on screen can satisfy a fresh wait. This
|
||||
was observed live: a marker echoed a minute earlier matched instantly on a new
|
||||
`from=now` wait. It is inherent to running the agent under a multiplexer, so the
|
||||
contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
|
||||
`echo $MARK`, then wait on `$MARK`), never a generic string like `BUILD OK`.
|
||||
|
||||
### Signals
|
||||
|
||||
| Signal | Source | Actually fires for |
|
||||
|--------|--------|--------------------|
|
||||
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `shell`: **once only**, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization) |
|
||||
| `working` | the session's own `working` event | `claude` only in practice (spinner and work-keyword detection are Claude output formats) |
|
||||
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` only |
|
||||
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook | `claude` only, and rarer than it looks: see below |
|
||||
| `exit` | no process is behind the session | every mode |
|
||||
|
||||
`stop` is the signal to orchestrate on where it exists; `idle` is a heuristic
|
||||
fallback that can flap mid-turn when a spinner pauses. The default set when `until`
|
||||
is omitted is `stop,idle,exit` (`exit` is in there so a worker that crashes resolves
|
||||
the wait promptly instead of burning the caller's whole timeout on something that
|
||||
can no longer happen). On a `claude` worker, prefer an explicit `until=stop,exit`
|
||||
once the session is up: the default set's `idle` also resolves on a spinner pause,
|
||||
and on a fresh session the **startup** `idle` (emitted when the CLI first comes up)
|
||||
can land inside your first wait window and report a turn that never ran. Measured:
|
||||
a session parked on the trust dialog emits no *further* `idle`, so it is the
|
||||
startup transition, not the dialog, that produces the false success below.
|
||||
|
||||
⚠️ **`exit` means "nothing is running", which includes "not started yet".** The
|
||||
server answers from `pid === null` plus a mux-layer pane-death probe, and that
|
||||
covers a session that exited — including a worker that died *inside* its tmux pane
|
||||
while the local attach client (and therefore `pid`) lives on — one that was
|
||||
detached, and one that was **created but never started**. So the first wait
|
||||
after `POST /api/v1/sessions` returns `{"signal":"exit","immediate":true}` in
|
||||
milliseconds, and reading that as "the worker died" is wrong: it means start it, or
|
||||
wait for it to come up. `status` is carried alongside so nothing is hidden. The
|
||||
alternative (trusting `status`) is worse, because a dead PTY parks the session at
|
||||
`status: "idle"`, which would answer the default wait with `immediate: true` for a
|
||||
worker that has crashed. A worker dying while a wait is parked resolves it within
|
||||
a few seconds (a background death-watcher), not at the timeout.
|
||||
|
||||
⚠️ **`blocked` is reachable less often than the table suggests.** It fires on two
|
||||
hooks, and the default configuration suppresses one of them: Codeman spawns claude
|
||||
with `--dangerously-skip-permissions`, so permission prompts do not happen unless the
|
||||
instance is switched to the `auto` Claude mode (App Settings), or the caller is a
|
||||
multi-user account without the bypass grant, which is forced to `--permission-mode
|
||||
auto`. What does still fire under the default is `elicitation_dialog`, the agent
|
||||
asking the user a question. So `until=stop,blocked,exit` is a reasonable belt on a
|
||||
long turn, but a worker that never comes back is far more likely to be working than
|
||||
blocked, and polling `blocked` alone will sit at its timeout.
|
||||
|
||||
⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell
|
||||
session emits its one `idle` at startup and then stays `status: "idle"` forever,
|
||||
whatever the pane is doing, so it never emits a *transition*. Since send-and-wait
|
||||
requires a transition (and so does `fresh=1`), both can only time out there:
|
||||
a documented default `wait` on a shell worker running `sleep 4` times out at the
|
||||
full 25 s. Synchronize hook-less sessions with `wait-output` and a unique marker
|
||||
instead. The same caution applies to the external CLIs.
|
||||
|
||||
### Readiness is not a signal
|
||||
|
||||
Nothing here reports "the agent is ready for a prompt", and no combination of
|
||||
`until`/`fresh` synthesizes one. A freshly created session reads as `exit` (above),
|
||||
and a `claude` worker in a brand-new case comes up on the CLI's **trust dialog**,
|
||||
which contains a ❯ prompt of its own. Send-and-wait posted at that moment types the
|
||||
prompt into the dialog, where the `\r` never gets past it, while the session's
|
||||
startup `idle` lands inside the wait window: the wait resolves on `idle` in a
|
||||
couple of seconds with `timedOut: false`, which looks exactly like a completed
|
||||
turn.
|
||||
|
||||
The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is
|
||||
non-null, then `wait-output` for the composer's own marker (`bypass`, the status
|
||||
bar of a CLI spawned in bypass mode) with a short timeout, handling the trust
|
||||
dialog only as the bounded fallback (`trust` matched → send `\r` → wait for
|
||||
`bypass` again). Do not probe `trust` first and Enter blindly: the dialog text
|
||||
stays in the terminal buffer for the life of the session, so a `trust` probe with
|
||||
`from=buffer` keeps matching on every later run and the Enter lands in a ready
|
||||
composer. A worked version is in
|
||||
[`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli).
|
||||
|
||||
### `GET /api/v1/sessions/:id/wait`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback |
|
||||
| `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` |
|
||||
| `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time |
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000"
|
||||
```
|
||||
|
||||
Both GET wait routes answer with `Cache-Control: no-store`, because the documented
|
||||
pattern polls one identical URL in a loop and a cached `{"timedOut":true}` would
|
||||
turn that loop into a busy spin. `POST .../input` sends no cache header (it is a
|
||||
POST, which is not heuristically cacheable).
|
||||
|
||||
⚠️ **Unknown query parameters are ignored, not rejected**, with one exception
|
||||
(`regex`, below). In particular `match=` on `/wait` is silently dropped and you get
|
||||
a plain signal wait, so check the endpoint path before blaming the parameters.
|
||||
|
||||
### `GET /api/v1/sessions/:id/wait-output`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found |
|
||||
| `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing |
|
||||
| `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking |
|
||||
| `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` |
|
||||
|
||||
**Matching is literal, never a pattern.** A `regex` parameter is rejected with a
|
||||
`400` rather than ignored, so a caller that assumed otherwise finds out immediately
|
||||
instead of waiting on the wrong thing. The reasoning is in
|
||||
[`architecture-invariants.md`](architecture-invariants.md#agent-wait-primitives).
|
||||
|
||||
#### What the matcher actually sees
|
||||
|
||||
The matcher scans the raw PTY stream, **normalized**: ANSI escape sequences are
|
||||
stripped — CSI, OSC, and the charset-designation escapes a stock bash prompt emits
|
||||
on every line (`ESC ( B`), so `match=tnode:` matches a prompt that renders
|
||||
`…@tnode:` — a partial escape arriving at a chunk boundary is held back until its
|
||||
tail arrives, and a match may straddle PTY chunks: `printf STRAD; sleep 1; printf
|
||||
DLEQQ` is matchable as `STRADDLEQQ` (all measured live). Three caveats remain:
|
||||
|
||||
⚠️ **It is still the byte stream, not the rendered pane.** `GET .../terminal`
|
||||
answers from a tmux screen capture (`data.source: "mux-visible"`), the finished
|
||||
picture; the matcher sees the stream that painted it. For linear output the two
|
||||
agree once escapes are stripped, but a full-screen TUI composes its picture with
|
||||
cursor positioning, so what the pane shows and what the stream carries can differ.
|
||||
Seeing your string in `terminal?tail=` makes a match likely, not guaranteed.
|
||||
|
||||
⚠️ **A TUI's text can arrive without its spaces.** Claude Code positions words
|
||||
with cursor moves rather than printing spaces, so screen text can reach the
|
||||
matcher as `Quicksafetycheck:Isthisaprojectyoucreated...`. Whether a given phrase
|
||||
keeps its spaces depends on how the TUI happened to draw it (measured: `I trust
|
||||
this folder` matched, `Quick safety check` did not), so a multi-word `match`
|
||||
against a TUI pane is unreliable rather than impossible. Match a **single
|
||||
space-free token**, ideally one you printed yourself. Plain command output (a
|
||||
shell worker, an `echo`) keeps its spaces.
|
||||
|
||||
⚠️ **The returned `snippet` is a rendering of the matched text, not a quotation of
|
||||
it.** It is cut from the same normalized stream the match ran against, then
|
||||
cleaned for display: remaining raw control bytes are removed (an agent pipes the
|
||||
snippet into its own terminal, so a worker's bytes must not be able to reset that
|
||||
display) and blank runs are collapsed. A printable needle that matched will appear
|
||||
in it; a needle containing control bytes or a blank run may not survive verbatim.
|
||||
|
||||
```bash
|
||||
MARK="DONE_$RANDOM"
|
||||
curl -sG "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=$MARK" --data-urlencode 'timeout=120000'
|
||||
```
|
||||
|
||||
Build the query with `-G --data-urlencode` rather than by hand: a `+` in a
|
||||
hand-written query string decodes to a space.
|
||||
|
||||
### `POST /api/v1/sessions/:id/input` with `wait`
|
||||
|
||||
Two optional fields on the existing endpoint:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" |
|
||||
| `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` |
|
||||
|
||||
Both are `nullish`, so an explicit `null` from `JSON.stringify` is accepted as
|
||||
"absent" rather than failing validation. That is deliberate: `.optional()` would
|
||||
reject it, which has shipped as a real bug twice.
|
||||
|
||||
The input must end with `\r` (a real carriage return in the JSON string): Enter is
|
||||
sent only when the input contains one, so text without it is typed onto the
|
||||
worker's prompt but never submitted, and the wait then runs its full timeout on a
|
||||
turn that never started. Verified live; this is the most common silent failure on
|
||||
this endpoint.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"run the tests\r","useMux":true,"clientId":"agent-1","seq":1,
|
||||
"wait":"stop","waitTimeout":600000}'
|
||||
```
|
||||
|
||||
A **tagged duplicate** (a `clientId` + `seq` pair the server has already applied)
|
||||
still honors `wait`, because the caller's question is unanswered, but it answers
|
||||
from the session's current state rather than requiring a new transition: the
|
||||
original turn may be long over. It comes back as
|
||||
`"delivered": false, "duplicate": true`.
|
||||
|
||||
### Response
|
||||
|
||||
All three nest the wait result under `data.wait`, so one client helper works against
|
||||
any of them:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464",
|
||||
"status": "idle",
|
||||
"limitPaused": false,
|
||||
"wait": {
|
||||
"signal": "stop", "until": ["stop", "idle", "exit"],
|
||||
"timedOut": false, "immediate": false, "ended": false, "aborted": false,
|
||||
"waitedMs": 8421, "timeoutMs": 60000
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
`POST .../input` returns the same `wait` object alongside `delivered`, `duplicate`,
|
||||
`status` and `limitPaused`. `POST .../input` **without** `wait` is unchanged and
|
||||
still returns `{"success": true, "data": {}}`.
|
||||
|
||||
⚠️ `delivered: false` has **two** meanings, and they must be told apart by
|
||||
`duplicate`: with `duplicate: true` the input was suppressed as an already-applied
|
||||
redelivery (harmless, the turn it refers to may be long over), while with
|
||||
`duplicate: false` the **write failed** (typically no PTY behind the session). A
|
||||
client that reads `delivered === false` as "duplicate" silently treats a failed send
|
||||
as a success.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) |
|
||||
| `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) |
|
||||
| `wait.matched` | boolean | the string appeared (`/wait-output` only) |
|
||||
| `wait.match` | string | the literal that was searched for (`/wait-output` only) |
|
||||
| `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) |
|
||||
| `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` |
|
||||
| `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) |
|
||||
| `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met |
|
||||
| `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome |
|
||||
| `wait.waitedMs` | number | wall-clock ms actually spent waiting |
|
||||
| `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied |
|
||||
| `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand |
|
||||
| `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard |
|
||||
|
||||
Read the outcome by discriminator, in this order:
|
||||
|
||||
1. `wait.signal !== null` (or `wait.matched === true`): the thing happened.
|
||||
2. `wait.timedOut`: a poll boundary. Loop again.
|
||||
3. `wait.ended` or `wait.aborted`: the wait answered nothing, because the session is
|
||||
gone or was never running. Re-check the session instead of looping.
|
||||
|
||||
`wait.immediate` is not a fourth outcome: it rides along with the first one and
|
||||
means the condition already held at call time, so nothing was actually waited for.
|
||||
If that is not what you meant, you wanted `fresh=1` or the send-and-wait form. Note
|
||||
that `{"signal":"exit","immediate":true}` on a session you just created is the
|
||||
not-started-yet case, not a crash.
|
||||
|
||||
**The timeout is clamped, so read it back.** A request for 1800000 ms is silently
|
||||
reduced to the server's ceiling (600000 ms by default, operator-tunable), and a
|
||||
request for 1 ms is raised to 1000 ms. `wait.timeoutMs` is the value that was
|
||||
applied. Without checking it, a caller that asked for 30 minutes and got 10 will
|
||||
read the timeout as "the worker is wedged" and kill a session that was working fine.
|
||||
|
||||
### Errors
|
||||
|
||||
| `errorCode` | HTTP | When |
|
||||
|-------------|------|------|
|
||||
| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that installs no hooks (the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` |
|
||||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
|
||||
| `SESSION_BUSY` | 409 | this session's waiter cap is full |
|
||||
| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem |
|
||||
|
||||
The two capacity codes are deliberately different. A process-wide cap reported as
|
||||
`SESSION_BUSY` would tell the caller to switch sessions, which cannot help. The
|
||||
error message names the cap that was hit.
|
||||
|
||||
⚠️ A `401` is **not** in this table and is not an envelope at all (see
|
||||
[Response envelope](#response-envelope)). It matters most here: a polling loop that
|
||||
pipes each wait straight into `jq` fails with a parse error on every iteration
|
||||
against a password-protected server, which reads as "the wait endpoints are broken".
|
||||
Check the status first.
|
||||
|
||||
The per-session cap is a **combined** budget: signal waiters and output waiters
|
||||
count against the same 16, not 16 of each. An abandoned request no longer holds its
|
||||
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.
|
||||
|
||||
## Authentication
|
||||
|
||||
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -149,9 +149,17 @@ to Claude as a system reminder. This implies `"async": true`; ordinary async
|
||||
hooks do not wake an idle turn, and their output waits for the next interaction.
|
||||
|
||||
Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts
|
||||
the background task ID from the Bash result, watches the session transcript for
|
||||
the matching completion notification, and exits 2. It does not send terminal
|
||||
input, so it cannot submit a user's partially written prompt.
|
||||
the background task ID from the Bash result, watches the originating transcript
|
||||
and, for subagents, the top-level parent transcript for the matching completion
|
||||
notification, and exits 2. Claude records a subagent's Bash result in its
|
||||
`subagents/agent-*.jsonl` file but queues completion in the lead session JSONL.
|
||||
The task ID keeps each wake targeted. The helper does not send terminal input,
|
||||
so it cannot submit a user's partially written prompt.
|
||||
|
||||
For script-dispatched Codex work, `codex-run.sh` writes the final response
|
||||
between `CODEMAN_RESULT_BEGIN/END` markers in the background task output. The
|
||||
rewake helper includes a maximum of 64 KiB of that report in its feedback. UI
|
||||
subagent discovery and dispatcher result delivery are separate contracts.
|
||||
|
||||
### Notification
|
||||
|
||||
@@ -219,6 +227,16 @@ Or to allow exit:
|
||||
|
||||
**Use Cases**: Control nested loops, verify subagent output.
|
||||
|
||||
The hook input includes `agent_id`, `agent_transcript_path`, and
|
||||
`last_assistant_message`. Like `Stop`, a command hook can return
|
||||
`{"decision":"block","reason":"..."}` to keep the subagent running and feed
|
||||
the reason back to it.
|
||||
|
||||
Codeman uses this to prevent premature reports from workers that still own live
|
||||
Monitor or background-Bash processes. It derives candidate task IDs from the
|
||||
subagent transcript, but requires a matching live Linux process descriptor for
|
||||
`tasks/<id>.output`; historical task text by itself is not treated as active.
|
||||
|
||||
### TeammateIdle
|
||||
|
||||
**When**: When an agent-team teammate is about to go idle.
|
||||
|
||||
+169
-7
@@ -44,6 +44,10 @@ is in [`api-reference.md`](api-reference.md).
|
||||
the payload at the top level rather than under `data`. Read defensively with
|
||||
`body.data ?? body`.
|
||||
|
||||
⚠️ A `401` is not an envelope at all: auth is rejected in a request hook that
|
||||
replies with the bare string `Unauthorized`, so parsing it as JSON throws. Branch on
|
||||
the status code before you parse, or a missing password looks like a broken endpoint.
|
||||
|
||||
**Already driving Codeman from an agent?** The README's
|
||||
[Programmatic Guide](../README.md#driving-codeman-from-an-agent--programmatic-guide)
|
||||
covers the in-session case: the `CODEMAN_MUX`, `CODEMAN_API_URL`,
|
||||
@@ -152,10 +156,17 @@ for (;;) {
|
||||
|
||||
## Seam 3: HTTP API and CLI
|
||||
|
||||
Around 199 handlers across 21 route files cover sessions, cases, files, cron,
|
||||
Around 200 handlers across 21 route files cover sessions, cases, files, cron,
|
||||
respawn, Ralph, the orchestrator, search, and admin. Each route module carries an
|
||||
`@fileoverview` describing its endpoints.
|
||||
|
||||
If the caller is an agent running _inside_ a Codeman session, install the packaged
|
||||
agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo
|
||||
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
|
||||
[--case <name>]`, or the synced `agentSkillEnabled` App Setting for automatic
|
||||
per-case injection on Claude session create). The skill carries the guard, the
|
||||
safety rules, and verified wait/orchestration recipes.
|
||||
|
||||
The common ones:
|
||||
|
||||
```bash
|
||||
@@ -167,10 +178,12 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"workingDir":"/home/me/project","mode":"claude"}'
|
||||
|
||||
# Send a prompt (single-line only)
|
||||
# Send a prompt (single-line only, and it must end with \r: Enter is sent only
|
||||
# when the input contains a carriage return; without it the text sits on the
|
||||
# session's prompt unsubmitted)
|
||||
curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"run the tests","useMux":true}'
|
||||
-d '{"input":"run the tests\r","useMux":true}'
|
||||
```
|
||||
|
||||
`POST .../input` also accepts `clientId` (stable per client, max 128 chars) and
|
||||
@@ -178,6 +191,124 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \
|
||||
at-most-once, so retrying after a dropped connection cannot type the prompt
|
||||
twice. Omit them entirely rather than sending `null`.
|
||||
|
||||
It also accepts `wait` and `waitTimeout`, which hold the response open until the
|
||||
session finishes the turn you just started. `wait` is `true` (the default signal
|
||||
set) or a comma list of `idle,working,stop,blocked,exit`; the result comes back
|
||||
under `data.wait`. Sending them changes nothing for callers that do not: without
|
||||
`wait` the response is still `{"success": true, "data": {}}` and the write is still
|
||||
fire-and-forget. The two interact with `clientId` / `seq` in one way worth knowing:
|
||||
a **tagged duplicate** (a pair the server already applied) skips the write but still
|
||||
waits, answering from the session's current state rather than blocking for a
|
||||
transition that already happened. It reports `"delivered": false, "duplicate": true`.
|
||||
|
||||
### Waiting instead of polling
|
||||
|
||||
Three calls block until something happens: `GET /api/v1/sessions/:id/wait` (a
|
||||
lifecycle signal), `GET /api/v1/sessions/:id/wait-output` (a literal string in the
|
||||
output), and the `wait` field above. Full parameter and response tables are in
|
||||
[`api-reference.md`](api-reference.md#long-polling-agent-wait). Four things decide
|
||||
whether your integration works, and the last one is what actually bites:
|
||||
|
||||
- **A timeout is a `200` with `wait.timedOut: true`**, not an error. Loop over short
|
||||
waits rather than issuing one long one, because `tailscale serve` and cloudflared
|
||||
both cut idle connections and a single 10-minute call is the pattern most likely
|
||||
to die in the field.
|
||||
- **`wait.timeoutMs`** is the timeout after server-side clamping (600 s ceiling by
|
||||
default). Read it rather than assuming you got what you asked for.
|
||||
- **`stop` and `blocked` only exist for `claude` sessions**, and on a `shell` session
|
||||
even `idle` fires only once at startup, so send-and-wait there can only time out.
|
||||
See the Gotchas below.
|
||||
|
||||
⚠️ **There is no readiness signal, and skipping readiness is the failure that looks
|
||||
like success.** A session reports `idle` before its CLI has spawned, and a `claude`
|
||||
worker in a brand-new case comes up on the CLI's **trust dialog**, which has a ❯
|
||||
prompt of its own. Prompt it at that moment and the text lands in the dialog, the
|
||||
`\r` does not get past it, and the session's startup `idle` lands inside the wait
|
||||
window: the wait resolves on `idle` in a couple of seconds with `timedOut: false`,
|
||||
indistinguishable from a finished turn. Wait for the pid, then wait for the
|
||||
composer, answering the dialog only as the bounded fallback.
|
||||
|
||||
A worked orchestration: start a worker, get it ready, prompt it, wait, clean up.
|
||||
|
||||
```bash
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}" # auto-set in-session, correct scheme included
|
||||
AUTH=(-u "admin:$CODEMAN_PASSWORD") # omit entirely if no password is set
|
||||
CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on --https installs (self-signed cert)
|
||||
|
||||
# 1. Start a worker session (creates the case if it does not exist yet).
|
||||
# The guard matters: a TLS or auth failure otherwise leaves SID empty and every
|
||||
# later step "succeeds" against nothing.
|
||||
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}' | jq -r '.data.sessionId')
|
||||
[ -n "$SID" ] && [ "$SID" != null ] || { echo "quick-start failed"; exit 1; }
|
||||
|
||||
# 2. READINESS: composer marker first, trust dialog only as the bounded fallback.
|
||||
# Skip this and step 3 reports a turn that never ran. Do NOT probe trust first
|
||||
# and Enter blindly: the dialog text stays in the buffer for the life of the
|
||||
# session, so on every later run that probe matches stale text and the Enter
|
||||
# lands in a ready composer. Match single tokens only: TUI text can arrive
|
||||
# without its spaces. Stage 1 is short on purpose (an already-trusted case
|
||||
# matches in <1 s; a first-run case can never pass it and pays it in full).
|
||||
until [ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ]
|
||||
do sleep 1; done
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=5000') # composer's status bar = ready
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=2000')
|
||||
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' -d '{"input":"\r","useMux":true}' >/dev/null
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=45000' >/dev/null
|
||||
fi
|
||||
|
||||
# 3. Send the prompt AND register the wait in one call, so the answer cannot be
|
||||
# the previous turn's idle state. Single line only, ending in \r (otherwise
|
||||
# Enter is never sent and this wait times out on a turn that never started).
|
||||
W=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize the failures\r","useMux":true,
|
||||
"clientId":"orchestrator","seq":1,"wait":"stop,exit","waitTimeout":60000}' \
|
||||
| jq -c '.data.wait')
|
||||
|
||||
# 4. That first wait probably timed out (60 s). Keep going in SHORT waits.
|
||||
for _ in $(seq 1 30); do
|
||||
[ "$(jq -r '.timedOut' <<<"$W")" = 'true' ] || break # signal fired, or wait ended
|
||||
W=$("${CURL[@]}" \
|
||||
"$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000" | jq -c '.data.wait')
|
||||
done
|
||||
jq -r 'if .ended or .aborted then "worker is not running"
|
||||
elif .timedOut then "still working after 30 waits"
|
||||
else "signal: \(.signal)" end' <<<"$W"
|
||||
|
||||
# 5. Read what it produced, then delete the session YOU created, by exact id.
|
||||
# ⚠️ NOT /output: its textOutput is empty for every tmux-backed session.
|
||||
# `tail` counts BYTES, and the payload is terminal data with ANSI in it.
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||
"${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
|
||||
```
|
||||
|
||||
Waiting on a marker instead of a signal is the form that works in **every** mode,
|
||||
and the only one that works on a `shell` session:
|
||||
|
||||
```bash
|
||||
# ⚠️ Split the marker so the typed line never contains it: your own keystrokes echo
|
||||
# into the output stream, so an unsplit marker matches before the command has run.
|
||||
# `from=buffer` also catches a marker that printed before the wait registered.
|
||||
N=$RANDOM
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"input\":\"M=DONE; npm test; echo \${M}_$N rc=\$?\r\",\"useMux\":true}"
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=60000' | jq '.data.wait'
|
||||
```
|
||||
|
||||
For shell scripting, the `codeman` CLI is the same surface without the HTTP
|
||||
plumbing:
|
||||
|
||||
@@ -221,10 +352,41 @@ Every one of these has cost somebody real time.
|
||||
shipped bugs more than once.
|
||||
- **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled
|
||||
simple-request CSRF, so it is deliberate. Send `application/json`.
|
||||
- **Prompts are single-line.** With `useMux: true` the server delivers your text
|
||||
and then Enter as two separate writes, so you do not append `\r` yourself. A
|
||||
multi-line string breaks the agent's Ink-based input handling: send one line,
|
||||
or split it across calls.
|
||||
- **Prompts are single-line and must end with `\r`.** The server splits your text
|
||||
and Enter into two separate tmux writes (Ink needs them apart), but it sends the
|
||||
Enter **only when the input contains a carriage return**. Without it your text
|
||||
sits on the prompt unsubmitted, which is the single most common "the wait
|
||||
endpoints don't work" report: the wait runs its full timeout on a turn that never
|
||||
started. Newlines inside the string are stripped rather than rejected, so
|
||||
`"echo A\necho B\r"` runs the single joined command `echo Aecho B`: send one line
|
||||
per call.
|
||||
- **`wait-output`'s `from=now` is not "printed after you asked".** tmux repaints
|
||||
the visible screen on attach, on resize, and on any TUI redraw, and a repaint
|
||||
arrives as ordinary output, so text already on screen can satisfy a fresh wait.
|
||||
Observed live: a marker echoed a minute earlier matched instantly. Use a marker
|
||||
unique to each call, and build it so the typed line never contains it (your own
|
||||
keystrokes echo into the stream). Matching is a literal substring, so `regex=` is
|
||||
rejected with a `400` rather than ignored.
|
||||
- **`wait-output` matches the normalized PTY stream, not the screen.** ANSI escape
|
||||
sequences are stripped (the `ESC ( B` charset escape a bash prompt emits on every
|
||||
line included), a partial escape at a chunk boundary is held back until its tail
|
||||
arrives, and a match may straddle PTY chunks, so text you printed yourself
|
||||
matches reliably (`printf STRAD; sleep 1; printf DLEQQ` is matchable as
|
||||
`STRADDLEQQ`). What can still fail is TUI output: a full-screen TUI positions
|
||||
words with cursor moves, so its text can reach the matcher **without spaces** and
|
||||
a multi-word match is unreliable there. Match one short space-free token, ideally
|
||||
one you printed yourself, and keep it out of the typed line (your own keystrokes
|
||||
echo into the stream).
|
||||
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini` or
|
||||
`antigravity` sessions.** They come from Claude Code hooks, which no other mode
|
||||
installs, so only `idle`, `working` and `exit` exist there. Asking for them
|
||||
explicitly is a `400`; omitting `until` is safe, since the server drops them from
|
||||
the default set and echoes what it actually waited on as `wait.until`. Even in
|
||||
`claude` mode, a Docker case needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for hooks to
|
||||
reach the server at all, a remote-SSH case's hooks may never arrive, and a case
|
||||
written by Codeman < 1.13.0 against an `--https` install carries hook curls
|
||||
without `-k` that TLS-fail silently — a 1.13.0+ server rewrites them the next
|
||||
time a session starts in that case.
|
||||
- **Unwrap the envelope** before reading fields. `data` is not the response body.
|
||||
|
||||
## Publishing your integration
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Predictive write-through echo for codex
|
||||
|
||||
Zero-lag local echo for codex sessions via a second, mosh-style mode in the
|
||||
`xterm-zerolag-input` package: every keystroke goes to the PTY exactly as the
|
||||
1.12.2 overlay-disabled path did (byte-identical wire behavior), while a
|
||||
`PredictiveEchoAddon` simultaneously paints the predicted glyph at the predicted
|
||||
cell. When the real echo lands, the prediction is confirmed and its span removed
|
||||
(invisible swap: identical glyph beneath). Mispredictions drop via a mismatch
|
||||
cascade + TTL. Visual-only, self-healing.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Issues #218/#219/#220/#222 (one root cause) forced 1.12.2 to disable the
|
||||
LocalEchoOverlay for codex: buffer-until-Enter starves codex's per-keystroke TUI
|
||||
(live slash picker, arrows editing server-side composer state, composer
|
||||
rewrap/growth, paste_burst classification). Buffer mode is structurally
|
||||
incompatible with codex; write-through prediction is the only echo mode that
|
||||
can coexist with it.
|
||||
|
||||
## The reconciliation lesson (do not regress this)
|
||||
|
||||
`docs/local-echo-overlay-plan.md` ("What NOT to Do") documented that matching
|
||||
predictions against the raw output STREAM fails against Ink/TUI full-line
|
||||
redraws. This design reads the parsed terminal BUFFER instead (cells after
|
||||
xterm's parser ran), which converges to the same cells no matter how the bytes
|
||||
arrived. The Phase 0 recordings prove the point twice over: tmux converts
|
||||
codex's full-line redraws into minimal in-place deltas (an echo arrives as
|
||||
`e\x1b[K\x1b[20;80H...`), and codex itself paints word gaps with ECH+cursor-forward
|
||||
instead of spaces. Stream matching can never survive that; buffer diffing does
|
||||
not care.
|
||||
|
||||
## Phase 0 measurements (codex-cli 0.147.0 via tmux, 100x30, 2026-08-09)
|
||||
|
||||
Recorded with `scripts/dev/record-codex-frames.mjs` (production pipeline:
|
||||
codex inside tmux `status off`, chunks passed through the same full strip
|
||||
`session.ts _handleTerminalOutput()` applies to codex mode). Fixtures in
|
||||
`packages/xterm-zerolag-input/test/fixtures/codex/`; replay/measure with
|
||||
`scripts/dev/analyze-codex-frames.mjs <fixture>`.
|
||||
|
||||
| Question | Measured answer |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Composer signature | Cursor row starts `"› "` (U+203A + space), text begins col 2. Present when empty (placeholder), while typing, and while the slash picker filters. `CODEX_COMPOSER_ROW_RE = /^› /` |
|
||||
| Composer text color | Plain default foreground, zero SGR around echoed chars. Span `foregroundColor` default (theme fg) is an exact match |
|
||||
| Placeholder | Cycling hint text ("Use /skills...", "Improve documentation in @filename", ...) rendered AT the cursor cell. First prediction lands over placeholder glyphs: covered by the snapshot + cursor-advance rules |
|
||||
| Wrap | Word-wrap near `cols - 2`; continuation rows are indented 2 spaces WITHOUT `› `. The gate therefore suppresses predictions on wrapped lines: deliberate fallback to real echo, wrap was the #220 ghost zone. `edgeMarginCells = 4` |
|
||||
| Modal (trust dialog) | Cursor parks on `" Press enter to continue"`: no `› ` prefix, gate false, zero predictions painted while keystrokes still reach the PTY (the ghost eliminator) |
|
||||
| Streaming | Error/reconnect bursts render above a re-rendered composer that keeps the `› ` signature; end-of-frame cursor parks at the insertion point (col 2 of the composer row). Confirms the cursor-advance confirm rule and the no-drop-on-baseY rule |
|
||||
| Echo shape under tmux | tmux emits minimal deltas for simple echoes and full repaints for busy frames; both converge in the parsed buffer |
|
||||
| Slash picker | Picker rows render below; the cursor row keeps the composer signature and advances per filter char, so predictions stay active while filtering (#222 surface) |
|
||||
|
||||
Constants decided at the Phase 0 gate: `CODEX_COMPOSER_ROW_RE = /^› /`,
|
||||
`ttlMs = 1000`, `maxPending = 32`, `cursorGraceMs = 150`, `edgeMarginCells = 4`,
|
||||
span colors = theme defaults, `underlinePredictions = false`.
|
||||
|
||||
## Algorithm
|
||||
|
||||
See `PredictiveEchoAddon` in
|
||||
`packages/xterm-zerolag-input/src/predictive-echo-addon.ts`. Summary of the
|
||||
rules and why each exists:
|
||||
|
||||
- **State**: ordered `PredictionRecord[]` (`seq`, `char`, `width`, cumulative
|
||||
`offsetCells`, `snapshot` of the cell at predict time, `sentAt`,
|
||||
`mismatches`), plus a run `_anchor {row, col}` captured when the outstanding
|
||||
count goes 0 -> 1. Positions are FIXED at predict time; confirmation deletes
|
||||
spans and never re-lays-out, so partial confirmation causes zero jitter.
|
||||
- **predictChar(ch)** runs an inline reconcile first and re-anchors whenever
|
||||
outstanding drains to zero (absorbs the echo-landed-between-keystrokes race).
|
||||
Guards: dims present, cursor numbers present, `viewportY === baseY`,
|
||||
`predictWhen` gate, single codepoint >= 0x20 (not 0x7f), width <= 2,
|
||||
`maxPending`, edge margin. Returns false = suppressed; the consumer sends the
|
||||
keystroke regardless.
|
||||
- **Coordinate base is `baseY`**: xterm's `cursorY` is baseY-relative, so
|
||||
absolute buffer line = `baseY + row`. `viewportY` would only coincide while
|
||||
the scrolled-to-bottom guards hold; the addon never relies on that.
|
||||
- **reconcile()** (debounced `onWriteParsed` microtask, inline in predictChar,
|
||||
TTL timer): clears everything when scrolled up; off-anchor-row cursor
|
||||
tolerated for `cursorGraceMs` then clears; PREFIX-ONLY confirm loop requiring
|
||||
cell match AND cursor advanced past the record (prevents false confirms
|
||||
against placeholder glyphs and makes identical in-place tmux repaints a
|
||||
no-op); TWO-PASS mismatch rule (a cell that is neither snapshot nor predicted
|
||||
char must persist across two passes before cascading the drop: a half-parsed
|
||||
row on pass N is fully redrawn a few ms later); TTL drop of the stale suffix.
|
||||
- **No drop on baseY change**: codex streams push lines to history while the
|
||||
composer stays viewport-pinned; predictions are row-relative to the pinned
|
||||
composer and remain valid (measured above).
|
||||
- **Anchor hold** (added by the independent post-build review): after any wire
|
||||
input whose cursor effect the display has not shown yet (backspace with
|
||||
nothing outstanding = deleting echoed text, every 'clear'-classified input,
|
||||
an IME/plain-paste 'text' commit, and the bypass send paths), new
|
||||
predictions are suppressed until the next PARSED write. Anchoring on the
|
||||
stale cursor painted ghosts one cell off ("tehh" on backspace-then-retype
|
||||
within RTT), blank-neutral and therefore TTL-lived. Worst case is exactly
|
||||
one unpredicted keystroke: its own echo is a write, which releases the hold.
|
||||
- **predictBackspace()** pops the newest outstanding record (informational
|
||||
return; the consumer forwards `\x7f` unconditionally). Deleting already-echoed
|
||||
text renders at RTT in v1.
|
||||
- **CJK/wide**: 2-cell spans, stacking by cumulative visual width, leading-cell
|
||||
confirm. In Codeman, IME input never reaches the hook (`window.cjkActive`
|
||||
returns from onData first); package support exists for other consumers.
|
||||
|
||||
## Integration map (Codeman)
|
||||
|
||||
- Policy: `_localEchoPolicy` (`'buffer' | 'predict' | 'off'`) computed at the
|
||||
end of `_updateLocalEchoState()`; codex + `localEchoEnabled` -> `'predict'`
|
||||
while `_localEchoEnabled` stays false (every 1.12.2 consumer unchanged).
|
||||
- onData hook sits between the buffer block and Normal Mode, classifies via
|
||||
`classifyPredictInput()` (pure, on `window.CodemanTerminalInput`), never
|
||||
returns, try/catch-wrapped: the wire path below is byte-identical with the
|
||||
predictor active, absent, or throwing.
|
||||
- Composer gate: `isCodexComposerRow()` set via `setPredictWhen()` at
|
||||
construction (the vendor footer stays package-agnostic).
|
||||
- Second vendor bundle `vendor/xterm-predictive-echo.js` (postinstall + build);
|
||||
the zerolag bundle build command is untouched and its output byte-identical.
|
||||
Missing/broken bundle = plain 1.12.2 echo (`typeof PredictiveEchoOverlay ===
|
||||
'undefined'` guard).
|
||||
- Prediction clears on: tab switch, SSE reconnect init, `insertTerminalText`,
|
||||
`clearTerminalInput`, voice send, keyboard-accessory `sendKey`, resize, skin
|
||||
and font changes re-read style via `refreshFont()`.
|
||||
|
||||
## Risk register
|
||||
|
||||
Eliminated structurally: other-mode regression (zero edits to buffer
|
||||
addon/branches, byte-identical existing bundle, policy-matrix + byte-identity
|
||||
tests); bundle breakage (separate bundle, graceful degradation); wire
|
||||
corruption (no-return fall-through + try/catch + byte-identity pins at vm and
|
||||
E2E level); modal ghosts (measured predictWhen gate); false confirms
|
||||
(cursor-advance rule); mid-parse flicker drops (two-pass rule); wrap
|
||||
misplacement (edge margin + continuation-row gate fallback + off-row grace).
|
||||
|
||||
Accepted residuals (visual-only, self-healing <= ttlMs, kill-switchable via
|
||||
`localEchoEnabled` per device): no predictions on wrapped continuation lines
|
||||
(gate false there, deliberate); brief dropout during composer growth; DOM-span
|
||||
vs WebGL glyph rendering can differ subtly (same trade-off as the buffer
|
||||
overlay, same font recipe); typing during an unsynchronized half-frame can
|
||||
mis-anchor one run (mismatch/TTL cleans within 1s).
|
||||
|
||||
## Future work
|
||||
|
||||
RTT-adaptive TTL; mosh-style confidence gating (paint only after the link
|
||||
proves laggy); predicted backspace into echoed text; predict mode for shell
|
||||
prompts; unifying the small font/container duplication between the two addons
|
||||
once predict mode has proven out; continuation-line prediction behind a
|
||||
smarter composer-extent detector.
|
||||
@@ -25,6 +25,195 @@ What shipped vs. what this doc proposed:
|
||||
- **Finding 5 (remote probe)**: implemented (`probeRemoteCliVersion` over ssh, deferred at
|
||||
session start, same login-shell wrapper as the launch).
|
||||
|
||||
## RETEST FAILED (2026-08-07, after v1.12.0 shipped) — analysis round 2
|
||||
|
||||
mtiller retested on 1.12.0 and reports it is NOT fixed (issue #205 comment, 2026-08-07 12:12 UTC;
|
||||
issue reopened same day with clarifying questions: mouse vs trackpad, Shift+scroll behavior,
|
||||
Claude vs shell session on the phone, and an iOS full-tab-kill to rule out stale JS). Two
|
||||
failure signatures, now analyzed against the SHIPPED 1.12.0 code (not the pre-fix code):
|
||||
|
||||
1. **iPhone Safari (Claude session assumed)**: touch scrollback goes back only a limited
|
||||
amount and sometimes REPEATS blocks of text; unreliable.
|
||||
2. **Firefox on macOS (mouse)**: wheel does NOTHING at all, while Fn+Up (= PageUp) pages back
|
||||
through INTACT text.
|
||||
|
||||
### Ruled out by code reading
|
||||
|
||||
- deltaMode mishandling: `_wheelScrollLinesFloat` normalizes line/page/pixel units correctly;
|
||||
a Firefox line-mode notch yields ±3 lines. Not the bug.
|
||||
- Ephemeral transport: `_sendInputEphemeral` (app.js) has a POST fallback when WS is down.
|
||||
- Service worker: sw.js is network-first with cache fallback; it serves stale JS only when the
|
||||
fetch FAILS (flaky mobile connection can do this — relevant to "unreliable" on the phone,
|
||||
and the fixed `CACHE_NAME = 'codeman-v1'` never invalidates that offline copy).
|
||||
|
||||
### The load-bearing observation: PageUp works, the wheel does not
|
||||
|
||||
Fn+Up is a KEYBOARD event: xterm encodes PageUp and Claude pages its own transcript (intact
|
||||
text proves Claude-side history is fine and the PTY input path is fine). The wheel path is the
|
||||
capture-phase handler, and for a Claude session it has exactly two branches:
|
||||
|
||||
- **Forwarding branch** (`_shouldForwardWheelToApp` true): snap-to-bottom + SGR reports. If
|
||||
this branch ran, the user would see the same paging motion Fn+Up produces. They see nothing.
|
||||
- **Local branch** (gate false): `_smoothScrollBy` over xterm's local buffer. For a Claude
|
||||
pane in repaint mode, tmux keeps `history_size≈0`, so `?full=1` returns roughly one frame:
|
||||
the local buffer is structurally HOLLOW, the top-of-buffer re-pull recovers nothing, and the
|
||||
wheel looks completely dead. **This matches every observed detail on Firefox.**
|
||||
|
||||
So the working hypothesis is that mtiller's sessions evaluate the gate FALSE. The gate
|
||||
(`_shouldForwardWheelToApp`) has exactly four false-paths worth checking, in likelihood order:
|
||||
|
||||
1. **`terminalWheelLocalScrollback` opt-out is ON.** Plausible: a user whose scrolling was
|
||||
broken on 1.11.x may well have toggled "Wheel scrolls local history" while trying to fix
|
||||
it. On 1.12.0 that setting now routes the wheel to a hollow local buffer = dead wheel on
|
||||
desktop AND the stale-repaint-frames experience on the phone (see below). Ask, or check
|
||||
what the setting does on their export.
|
||||
2. **`cliVersion` missing — CONFIRMED BUG, independent of whether it is mtiller's**:
|
||||
`getClaudeCliVersion()` (utils/claude-cli-resolver.ts:124-148) caches its result
|
||||
process-wide including FAILURE: on any exception it sets `_claudeVersion = null`, and the
|
||||
guard is `!== undefined`, so a single failed/timed-out probe (5s `EXEC_TIMEOUT_MS`; PATH
|
||||
under systemd/launchd; transient fs hiccup) at the FIRST Claude session start disables
|
||||
wheel forwarding for every Claude session until the server restarts. Fix: cache success
|
||||
permanently, but let failure retry (retry on next call, or a short negative-cache TTL).
|
||||
Note that mtiller sees identical breakage on phone + iPad + laptop, which points at a
|
||||
SERVER-side/session-side cause exactly like this (cliVersion is shared by all devices)
|
||||
rather than anything browser-specific.
|
||||
3. **Claude Code genuinely < 2.1.187** on their machine: gate false BY DESIGN, but the
|
||||
resulting UX is a dead-end (no local history to fall back on).
|
||||
4. mouseTrackingMode non-none (a DECSET leaked past the strip, e.g. emitted before attach or
|
||||
split across chunks in a way the carry missed): would also kill the container handler via
|
||||
the early return. Least likely, checkable via `terminal.modes.mouseTrackingMode` in console.
|
||||
|
||||
### The iPhone symptoms fit the same gate-false story
|
||||
|
||||
Touch with gate false = local `scrollLines()` over whatever repaint frames accumulated:
|
||||
"repeats blocks of text" is literally what a buffer of successive overlapping repaint frames
|
||||
looks like; "limited amount" is its thinness; "unreliable" is burst-dependence (finding 2)
|
||||
PLUS the new re-pull being actively DESTRUCTIVE for repaint panes: `_maybeRefetchFullHistory`
|
||||
does `_resetTerminalForReplay()` then writes the fetched capture, and when that capture is
|
||||
one frame (Claude pane, `history_size≈0`) it REPLACES a multi-frame buffer with less than the
|
||||
user had, mid-scroll. Stale pre-1.12 JS on the phone (suspended Safari tab) remains possible
|
||||
until they confirm the tab kill.
|
||||
|
||||
### Fix directions, ranked
|
||||
|
||||
1. **Make the re-pull refuse downgrades** (`_maybeRefetchFullHistory`, app.js): if the fetched
|
||||
capture would yield FEWER buffer rows than currently present, skip the reset+rewrite and
|
||||
keep the richer buffer (optionally cache-mark the session "re-pull useless"). Small, safe,
|
||||
kills the "got worse after scrolling to top" class. Consider skipping the re-pull entirely
|
||||
for forwarding-capable modes where tmux keeps no history.
|
||||
2. **Rescue the gate-false Claude dead-end with PageUp forwarding**: when mode is `claude`,
|
||||
the gate is false, AND the local buffer has no scrollback (`baseY === 0`), translate wheel
|
||||
lines into coalesced PageUp/PageDown key sends (mtiller just proved Claude pages correctly
|
||||
on PageUp even on their version). Zero regression risk under that triple guard: sessions
|
||||
with real local history keep local scrolling; only the currently-dead path changes.
|
||||
Caveat: older Claude menus may react to PageUp; acceptable against "completely dead".
|
||||
3. **Audit `getClaudeCliVersion()` failure caching** (utils/claude-cli-resolver.ts): a cached
|
||||
empty probe must retry (with backoff), not poison the process.
|
||||
4. **Guard the opt-out setting's footgun**: if `terminalWheelLocalScrollback` is ON for a
|
||||
repaint-mode CLI session, local history is hollow; either scope the setting's effect to
|
||||
modes with real local scrollback, or pair it with fix 2's PageUp fallback so it still
|
||||
scrolls SOMETHING.
|
||||
5. **Add a one-line gate diagnostic**: log (once per session, console) WHY the wheel chose
|
||||
local vs forward: `{mode, cliVersion, optOut, trackingMode}`. The #205 thread is now two
|
||||
rounds deep on guesswork a single console line would have answered.
|
||||
|
||||
### What shipped for round 2 (branch `fix/scrollback-205-round2`)
|
||||
|
||||
All five directions above, implemented as ranked:
|
||||
|
||||
1. **Downgrade guard** — `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the rows a
|
||||
capture will occupy (ANSI stripped, `capture-pane -J` re-wrapping accounted for) and
|
||||
`_maybeRefetchFullHistory` (app.js) skips the reset+rewrite when that is more than one
|
||||
screen short of what xterm already holds. A refused session goes on
|
||||
`_fullHistoryRepullUseless`, which raises its re-pull cooldown from 4s to 60s so a hollow
|
||||
pane stops re-fetching. Measured A/B on a live Claude pane, same gesture, same buffer:
|
||||
guard off → 341 rows collapse to 42 and every seeded row is gone; guard on → 341 rows
|
||||
preserved. The tab-switch recovery it must not break still runs (shell buffer 401 → 44 on
|
||||
a tab switch → 401 again after scrolling to the top).
|
||||
2. **PageUp/PageDown fallback** — `_maybePageCliTranscript()` translates wheel/touch travel
|
||||
into coalesced `\x1b[5~` / `\x1b[6~` under the triple guard (claude mode, forwarding gate
|
||||
false, `baseY === 0`), through the same 40ms queue as the SGR reports. Half a screen of
|
||||
travel per page: the page key always jumps a whole screen, and a 1:1 mapping was
|
||||
unusably slow with a discrete wheel. Shift is excluded — it keeps meaning "local
|
||||
scrollback". Verified live: opt-out ON on a Claude session sends real PageUp/PageDown to
|
||||
the PTY where the wheel previously did nothing.
|
||||
3. **Probe caching** — `getClaudeCliVersion()` no longer caches failure. Success is kept for
|
||||
the process lifetime; a failed probe retries with a 1/2/4…15min backoff. The cache policy
|
||||
is a pure function (`resolveClaudeCliVersion`) so the retry semantics are unit-testable
|
||||
without spawning `claude`. The VITEST short-circuit now records nothing, where before it
|
||||
wrote a permanent null.
|
||||
4. **Opt-out footgun** — handled by pairing rather than by scoping: the setting keeps meaning
|
||||
exactly what it says (the wheel goes local), and fix 2 catches the case where "local" is
|
||||
empty. Scoping the setting away from repaint-mode CLIs would have silently overridden an
|
||||
explicit user choice. The App Settings tooltip now says to leave it off for Claude/Codex.
|
||||
5. **Diagnostic** — `_logScrollRouting()` prints one line per session per distinct decision:
|
||||
`[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…,
|
||||
cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. That
|
||||
single line answers every open question in the list below.
|
||||
|
||||
Still unanswered by code alone: whether mtiller's Claude Code is genuinely older than
|
||||
2.1.187 (false-path 3), and whether the iPhone was running stale JS. The diagnostic makes
|
||||
both self-reporting, so the retest ask is now "open the console and paste the `[scroll]` line".
|
||||
|
||||
### What to get from mtiller (some already asked)
|
||||
|
||||
- Shift+scroll behavior on Firefox (distinguishes hollow-local from handler-not-firing).
|
||||
- `claude --version` on the Mac (decides false-paths 2 vs 3).
|
||||
- App Settings → Input → "Wheel scrolls local history" state (false-path 1).
|
||||
- iPhone: Claude or shell session, and whether a full tab kill changes anything.
|
||||
- Browser console: `app.terminalUi?.terminal?.modes?.mouseTrackingMode` (false-path 4).
|
||||
|
||||
## ROUND 3 (2026-08-09): Codex wheel dead — CONFIRMED AND FIXED
|
||||
|
||||
DodgyBadger (Codex latest, Chrome, Windows 11): mouse wheel does nothing in a CODEX session
|
||||
while working fine in shell and web tabs; DRAGGING THE SCROLLBAR WORKS, so xterm's local
|
||||
buffer demonstrably has content for their codex pane. Analysis against the shipped code:
|
||||
|
||||
- `_shouldForwardWheelToApp` returns true UNCONDITIONALLY for `codex` (no version gate, unlike
|
||||
claude's `>= 2.1.187`), so every plain wheel tick is sent as SGR reports to Codex.
|
||||
- The "verified to scroll its transcript on SGR wheel reports" claim for codex predates
|
||||
current Codex builds; if Codex latest ignores SGR wheel, forwarding eats the gesture while
|
||||
the healthy local scrollback (proven by the working scrollbar) sits unused.
|
||||
- The #227 PageUp fallback cannot rescue this: it is gated to `claude` mode AND `baseY === 0`,
|
||||
and codex here has real local scrollback. The `[scroll]` diagnostic will still say
|
||||
`forward-sgr (mode=codex, ...)`, confirming the branch, worth asking the reporter to paste.
|
||||
|
||||
**CONFIRMED by the reporter's `[scroll]` line (2026-08-09, PR #227 comment)**:
|
||||
`forward-sgr (mode=codex, cliVersion=unknown, localScrollbackOptOut=false, mouseTracking=none,
|
||||
localScrollbackRows=967)`. Forwarding branch active, 967 rows of healthy local scrollback
|
||||
unused, Codex ignoring the SGR reports. Environment: Codex latest, Chrome, Windows 11.
|
||||
|
||||
**Measured against codex-cli 0.147.0** (isolated `tmux -L codexwheel`, fake `CODEX_HOME/auth.json`,
|
||||
history built with 401ing prompts), which settles it without needing a version gate at all:
|
||||
|
||||
| Probe | Result |
|
||||
| ---------------------------------------------- | ----------------------------------------------- |
|
||||
| `#{mouse_any_flag}` once the TUI is up | `0`: codex never enables mouse tracking |
|
||||
| `#{alternate_on}` | `0`: inline viewport, not an alt-screen pager |
|
||||
| `#{history_size}` while prompting | grows 3 → 32: the transcript goes to scrollback |
|
||||
| 6 × `\x1b[<64;10;10M` written to the pane | pane capture byte-identical, nothing happens |
|
||||
| control: literal `zz` | pane changes, so the probe can see changes |
|
||||
| `\x1b[<0;12;5M` + release (the click-tap path) | no change either: taps are no-ops, not garbage |
|
||||
|
||||
Codex has no in-app pager to drive: its history lives in the terminal's own scrollback, which is
|
||||
exactly what forwarding was stealing the gesture from. A version gate would be the wrong fix (and
|
||||
`cliVersion=unknown` means there is no codex probe to gate on anyway).
|
||||
|
||||
**Fix (shipped):** `_shouldForwardWheelToApp` now returns true for `claude >= 2.1.187` and nothing
|
||||
else. Codex falls to the normal local-scrollback path like shell/gemini/opencode, so wheel and touch
|
||||
scroll the same history the scrollbar drag was already scrolling. The claude-only PageUp fallback is
|
||||
untouched: codex never needs it, its local buffer is real. Taps stay hand-encoded for codex
|
||||
(`_sessionUsesServerMouseStrip`), measured harmless, so click-to-position is merely unavailable
|
||||
there rather than damaging. Lesson for the next mode added to the forward list: "it is a strip mode"
|
||||
proves nothing, write a real SGR report into a live pane and diff the capture first.
|
||||
|
||||
Verified end-to-end in Chromium against a live codex session on an isolated instance
|
||||
(`CODEMAN_INSTANCE=codexwheel`, port 5055, `envOverrides.CODEX_HOME` pointing at the fake auth
|
||||
dir): trusted `page.mouse.wheel` up now logs
|
||||
`[scroll] … → local-scrollback (mode=codex, …, localScrollbackRows=43)`, moves the viewport
|
||||
39 → 4 (back to the Codex banner), and sends ZERO bytes to the PTY. Unit coverage:
|
||||
`test/terminal-touch-tap.test.ts` ("only claude forwards — codex and gemini keep the local wheel").
|
||||
|
||||
Original plan follows.
|
||||
|
||||
## Reports
|
||||
|
||||
@@ -43,38 +43,22 @@ const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
|
||||
this.broadcast('session:terminal', { id: sessionId, data: syncData });
|
||||
```
|
||||
|
||||
## Client-Side Implementation (`app.js`)
|
||||
## Client-Side Implementation (`terminal-ui.js`)
|
||||
|
||||
### `batchTerminalWrite(data)`
|
||||
|
||||
1. Checks if flicker filter is enabled (optional, per-session)
|
||||
2. If flicker filter active: buffers screen-clear patterns (`ESC[2J`, `ESC[H ESC[J`, `ESC[nA`)
|
||||
3. Accumulates data in `pendingWrites`
|
||||
4. Schedules `requestAnimationFrame` if not already scheduled
|
||||
5. On rAF callback: checks for incomplete sync blocks (start without end)
|
||||
6. If incomplete: waits up to 50ms via `syncWaitTimeout`
|
||||
7. Calls `flushPendingWrites()` when complete
|
||||
|
||||
### `extractSyncSegments(data)`
|
||||
|
||||
- Parses DEC 2026 markers, returns array of content segments
|
||||
- Content before sync blocks returned as-is
|
||||
- Content inside sync blocks returned without markers
|
||||
- Incomplete blocks (start without end) returned with marker for next chunk
|
||||
4. Calls `_scheduleTerminalWriteFlush()` if no flush is pending
|
||||
5. The yielded callback clears its scheduled flag before calling `flushPendingWrites()`
|
||||
6. Large batches schedule their own next chunk until the queue is empty
|
||||
|
||||
### `flushPendingWrites()`
|
||||
|
||||
```javascript
|
||||
const segments = extractSyncSegments(this.pendingWrites);
|
||||
this.pendingWrites = ''; // Clear before writing
|
||||
for (const segment of segments) {
|
||||
if (segment && !segment.startsWith(DEC_SYNC_START)) {
|
||||
terminal.write(segment); // Skip incomplete blocks (start with marker)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Segments starting with `DEC_SYNC_START` are incomplete blocks awaiting more data. These are skipped (discarded if timeout forces flush).
|
||||
- Joins the queued terminal data and passes DEC 2026 markers through to xterm.js 6, which handles synchronized output natively.
|
||||
- Writes at most 32KB per yield for Codex and 64KB for other modes.
|
||||
- Requeues the remainder and immediately schedules another safe yield. A final large response therefore drains without waiting for another SSE event.
|
||||
|
||||
### `chunkedTerminalWrite(buffer, chunkSize=128KB)`
|
||||
|
||||
@@ -116,17 +100,15 @@ When detected, buffers 50ms of subsequent output before flushing atomically.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Incomplete sync blocks**: 50ms timeout forces flush (content discarded to prevent freeze)
|
||||
- **Incomplete sync blocks**: xterm.js retains synchronized output until its closing marker
|
||||
- **Large buffers**: Chunked writing prevents UI freeze
|
||||
- **Server shutdown**: Skips batching via `_isStopping` flag
|
||||
- **Session switch**: Clears flicker filter state, pending writes, and sync timeout (prevents cross-session data bleed)
|
||||
- **SSE reconnect**: `handleInit()` clears all pending write state
|
||||
|
||||
**Trade-off:** If a sync block is split across SSE packets and the end marker doesn't arrive within 50ms, the incomplete content is discarded. This prioritizes responsiveness over completeness. In practice this is rare since the server always sends complete `SYNC_START...SYNC_END` pairs and SSE typically delivers them atomically.
|
||||
|
||||
## DEC Mode 2026 Compatibility
|
||||
|
||||
Terminals that natively support DEC 2026 will buffer and render atomically. Terminals that don't support it ignore the escape sequences harmlessly. xterm.js doesn't support DEC 2026 natively, so the client implements its own buffering by parsing the markers.
|
||||
Terminals that natively support DEC 2026 buffer and render atomically. Codeman uses xterm.js 6, so the client passes the markers through instead of parsing or discarding partial blocks.
|
||||
|
||||
**Supporting terminals:** WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
|
||||
@@ -135,4 +117,4 @@ Terminals that natively support DEC 2026 will buffer and render atomically. Term
|
||||
| File | Key Functions |
|
||||
|------|---------------|
|
||||
| `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` |
|
||||
| `src/web/public/app.js` | `batchTerminalWrite()`, `extractSyncSegments()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` |
|
||||
| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `_scheduleTerminalWriteFlush()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` |
|
||||
|
||||
+25
-1
@@ -46,7 +46,11 @@ Codeman, including a phone that is not on the tailnet.
|
||||
|
||||
`direct` mode (a plain cross-origin iframe) still exists and is cheaper, but it only
|
||||
works for an HTTPS dashboard that permits framing. The **Test** button probes from
|
||||
the server and tells you which mode applies.
|
||||
the server and tells you which mode applies. Note what Test actually verifies:
|
||||
**server-to-upstream reachability, nothing else**. It does not exercise the browser
|
||||
sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, so a
|
||||
passing Test does not guarantee the embedded page will render (see the
|
||||
cookie-authenticated reverse proxy caveat below).
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
@@ -66,6 +70,17 @@ Even in trusted mode, Codeman never forwards its own credentials upstream: the
|
||||
`Authorization` header and the `codeman_session` cookie are stripped on the way out,
|
||||
so `CODEMAN_PASSWORD` cannot leak into a dashboard.
|
||||
|
||||
⚠️ **Sandboxed tabs may not work when Codeman itself is behind a
|
||||
cookie-authenticated reverse proxy** (Cloudflare Access, Authelia, oauth2-proxy and
|
||||
similar). The sandboxed frame is opaque-origin, so its stylesheet, script, and API
|
||||
requests do not carry the proxy's authentication cookie; the proxy redirects them to
|
||||
the login provider, where CORS/CSP kills them, and the embedded app renders
|
||||
unstyled or broken while the Codeman page around it works fine. Trusted mode
|
||||
(**Open sandboxed** off) keeps a real origin and the cookie, so it works. The
|
||||
**Test** button cannot catch this: it checks that the Codeman *server* can reach the
|
||||
upstream, not that a sandboxed *browser* frame can load assets through the public
|
||||
authentication layer.
|
||||
|
||||
## How the proxy authenticates
|
||||
|
||||
A sandboxed iframe is opaque-origin, so every request it makes is cross-site: the
|
||||
@@ -137,6 +152,15 @@ then every API call fails, which looks like the dashboard being broken.
|
||||
- **Login-protected dashboards need trusted mode**, since a sandboxed frame has no
|
||||
cookie jar. A server-side per-dashboard cookie jar would lift this and is the
|
||||
natural next step if it becomes annoying.
|
||||
- **Cookie-authenticated reverse proxies in front of Codeman break sandboxed tabs**
|
||||
(#238). The sandboxed frame's requests carry no auth cookie, so the proxy bounces
|
||||
them to its login provider and the app loads broken while Test reports reachable.
|
||||
Use trusted mode behind Cloudflare Access and friends; see the warning above.
|
||||
- **Slow endpoints and the upstream timeout** (#237). The proxy waits
|
||||
`CODEMAN_WEBVIEW_TIMEOUT_MS` (default 300s) for the upstream's response *headers*,
|
||||
then streams the body without any time bound; a header timeout is logged
|
||||
server-side and answered as a 502 that names the limit. WebSocket handshakes use
|
||||
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
|
||||
Generated
+14
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -4547,6 +4547,16 @@
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
@@ -12333,9 +12343,10 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.5.0",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -21,6 +21,8 @@
|
||||
"test:watch": "vitest --config config/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"test:ci": "vitest run --config config/vitest.ci.config.ts",
|
||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -159,6 +161,7 @@
|
||||
"dist",
|
||||
"scripts/postinstall.js",
|
||||
"scripts/fix-node-pty.mjs",
|
||||
"skills",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 55bff4a: Zero-lag predictive echo for Codex sessions (mosh-style write-through prediction).
|
||||
|
||||
Codex's per-keystroke composer forced 1.12.2 to disable the local-echo overlay (issues #218/#219/#220/#222), leaving Codex typing at full round-trip latency on remote links. This release adds a second echo mode instead of re-enabling the first: every keystroke still goes to the PTY exactly as before (byte-identical wire behavior, pinned by vm-level and end-to-end trace-equality tests), while the new `PredictiveEchoAddon` in `xterm-zerolag-input` 0.2.0 paints the predicted glyph at the predicted cell. When the real echo lands, the prediction is confirmed and its span removed (an invisible swap); mispredictions self-heal via a two-pass mismatch cascade and a TTL.
|
||||
- Reconciliation reads the parsed terminal buffer, never the raw stream: full-line redraws, ECH gap painting and tmux's in-place deltas all converge to the same cells. Confirmation requires the cell match PLUS a cursor advance, so placeholder glyphs and identical repaints never false-confirm; blank cells are neutral (codex clears its placeholder on the first echo).
|
||||
- Predictions paint only while the cursor sits on the measured Codex composer row (`/^› /`, codex-cli 0.147): trust/approval modals and wrapped continuation rows get no ghosts, deliberately falling back to real echo.
|
||||
- Ships as a SEPARATE `vendor/xterm-predictive-echo.js` bundle: the existing zerolag bundle is byte-identical (sha256-verified), and a missing or broken bundle degrades Codex to exact 1.12.2 behavior. The per-device `localEchoEnabled` toggle is the kill switch.
|
||||
- Claude/Gemini/OpenCode/Antigravity keep buffer mode untouched; shell stays off.
|
||||
- A post-build adversarial review added the anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, IME text commits) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run.
|
||||
- Tests: 55 new package tests including replay suites driven by fixtures recorded from a real codex TUI through the production tmux+strip pipeline (`scripts/dev/record-codex-frames.mjs`) and a 500-iteration seeded fuzz; new vm policy/wire-neutrality suites; a 10-scenario Playwright E2E against real codex covering the #218/#219/#220/#222 retests, byte-identity, and a simulated 300ms-RTT run. The package test suite now runs in CI.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **New addon: `PredictiveEchoAddon`, mosh-style write-through prediction.** The second echo mode for per-keystroke TUIs (OpenAI Codex's composer, live pickers) that buffer-until-Enter starves. Every keystroke is sent by the consumer immediately and unchanged; the addon paints the predicted glyph at the predicted cell and reconciles against the PARSED terminal buffer: confirmation requires the cell match plus a cursor advance past the record, foreign non-blank content on two consecutive passes cascades a drop, blank cells are neutral, a TTL bounds everything, and scroll/resize/sustained cursor moves clear the run. Visual-only by construction; it cannot gate, delay or rewrite input.
|
||||
- Anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, an IME text commit) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run (worst case: exactly one unpredicted keystroke).
|
||||
- New exports: `PredictiveEchoAddon`, `PredictiveEchoOptions`, `PredictionState`, plus the long-intended `charCellWidth` / `stringCellWidth` helpers.
|
||||
- `XtermTerminal` type gains OPTIONAL members (`buffer.active.cursorX/cursorY`, `getLine().getCell?`, `onWriteParsed?`, `onResize?`). Additive only: existing consumers and mocks are unaffected.
|
||||
- IIFE build exposes `window.PredictiveEchoAddon` and a self-activating `window.PredictiveEchoOverlay`, alongside the unchanged `ZerolagInputAddon` / `LocalEchoOverlay` globals.
|
||||
- Tests: 52 new (30 addon-law specs, renderer geometry, 6 replay suites driven by fixtures recorded from real codex 0.147 through tmux + the production strip, and a 500-iteration seeded fuzz with per-op invariants). `@xterm/headless` as a devDependency; runtime dependencies remain zero.
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-175-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||
</p>
|
||||
</p>
|
||||
@@ -46,6 +46,15 @@ Same keystroke, same link. The only difference is who you wait for: the server,
|
||||
|
||||
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
||||
|
||||
Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
|
||||
|
||||
| Addon | Model | Use when |
|
||||
| --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ZerolagInputAddon` | **Buffer**: hold keystrokes locally, flush on Enter | The remote side is a line-oriented prompt (shells, REPLs, Claude Code's composer) that only needs the finished line |
|
||||
| `PredictiveEchoAddon` | **Predictive write-through**: send every keystroke immediately, paint a prediction, confirm against the parsed buffer | The remote side is a per-keystroke TUI (OpenAI Codex's composer, live pickers) that buffering would starve |
|
||||
|
||||
`ZerolagInputAddon` is documented below; jump to [PredictiveEchoAddon](#predictiveechoaddon-write-through-prediction) for the second mode.
|
||||
|
||||
## Why this one
|
||||
|
||||
| | |
|
||||
@@ -268,6 +277,110 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
|
||||
---
|
||||
|
||||
## `PredictiveEchoAddon` (write-through prediction)
|
||||
|
||||
Buffering is the wrong model for TUIs that react to every keystroke: a slash
|
||||
command picker filters live, arrows edit server-side state, the composer
|
||||
rewraps as it grows. For those, `PredictiveEchoAddon` works like
|
||||
[mosh](https://mosh.org/): the keystroke goes to the PTY **immediately and
|
||||
unchanged**, and the addon simultaneously paints the predicted glyph at the
|
||||
predicted cell. When the real echo lands, the prediction is confirmed and its
|
||||
span removed: an invisible swap, identical glyph beneath. Mispredictions
|
||||
self-heal via a mismatch cascade and a TTL. It is visual-only by construction:
|
||||
nothing it does can gate, delay, reorder or rewrite what you send.
|
||||
|
||||
```typescript
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { PredictiveEchoAddon } from 'xterm-zerolag-input';
|
||||
|
||||
const terminal = new Terminal();
|
||||
const predictor = new PredictiveEchoAddon({
|
||||
// Optional: only predict when the cursor sits on a composer row
|
||||
predictWhen: (t) => {
|
||||
const buf = t.buffer.active;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
return !!line && /^› /.test(line.translateToString(true));
|
||||
},
|
||||
});
|
||||
terminal.loadAddon(predictor);
|
||||
|
||||
terminal.onData((data) => {
|
||||
const cps = Array.from(data);
|
||||
if (cps.length === 1) {
|
||||
const cp = cps[0].codePointAt(0);
|
||||
if (cp === 0x7f) predictor.predictBackspace();
|
||||
else if (cp >= 0x20) predictor.predictChar(data);
|
||||
else predictor.clearPredictions(); // Enter, Ctrl+C, ...
|
||||
} else if (data.charCodeAt(0) === 0x1b) {
|
||||
predictor.clearPredictions(); // nav keys, bracketed paste
|
||||
}
|
||||
pty.write(data); // ALWAYS, unconditionally
|
||||
});
|
||||
```
|
||||
|
||||
### How reconciliation works
|
||||
|
||||
Predictions are reconciled against the **parsed terminal buffer** (cells after
|
||||
xterm's parser ran), never the raw output stream. That distinction is
|
||||
load-bearing: TUIs redraw whole lines, paint gaps with `ECH` + cursor-forward
|
||||
instead of spaces, and multiplexers like tmux rewrite everything into minimal
|
||||
deltas. Stream matching breaks on all of that; buffer cells converge to the
|
||||
same values no matter how the bytes arrived.
|
||||
|
||||
A prediction is **confirmed** only when its cell shows the predicted glyph AND
|
||||
the cursor has advanced past it (so a placeholder that happens to match, or an
|
||||
identical in-place repaint, never false-confirms). A cell showing foreign
|
||||
non-blank content on two consecutive passes drops that prediction and all
|
||||
later ones (one pass tolerates half-parsed frames). Blank cells are neutral:
|
||||
they are what "not yet echoed" looks like. Whatever remains is dropped by TTL.
|
||||
Scrolling up, resizing, or a sustained cursor move clears the run. After a
|
||||
backspace into already-echoed text, a cleared input, or a multi-char commit,
|
||||
the addon **holds** new predictions until the next parsed write: the displayed
|
||||
cursor is stale for one round trip, and anchoring on it would paint ghosts one
|
||||
cell off (worst case: exactly one unpredicted keystroke, whose own echo
|
||||
releases the hold).
|
||||
|
||||
### API
|
||||
|
||||
```typescript
|
||||
predictChar(ch: string): boolean; // false = suppressed (still SEND the key)
|
||||
predictBackspace(): boolean; // pops the newest prediction (still send \x7f)
|
||||
clearPredictions(): void;
|
||||
reconcile(): void; // manual pass (no onWriteParsed available)
|
||||
setPredictWhen(fn | null): void; // swap the gate at runtime
|
||||
refreshFont(): void; // after font/theme changes
|
||||
get hasPredictions(): boolean;
|
||||
get state(): PredictionState; // { outstanding, confirmedTotal, droppedTotal, anchor }
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```typescript
|
||||
{
|
||||
zIndex?: number, // Default: 7
|
||||
underlinePredictions?: boolean, // Default: false (underline unconfirmed glyphs)
|
||||
foregroundColor?: string, // Default: terminal theme / computed .xterm-rows style
|
||||
backgroundColor?: string, // Default: terminal theme background
|
||||
ttlMs?: number, // Default: 1000
|
||||
maxPending?: number, // Default: 32
|
||||
cursorGraceMs?: number, // Default: 150
|
||||
edgeMarginCells?: number, // Default: 4 (suppress near the right edge)
|
||||
predictWhen?: (t) => boolean, // Default: predict everywhere
|
||||
}
|
||||
```
|
||||
|
||||
### Which addon should I use?
|
||||
|
||||
- The remote program shows a **line prompt** and ignores partial input:
|
||||
`ZerolagInputAddon`. You also get backspace-before-send and batching.
|
||||
- The remote program **reacts per keystroke** (pickers, filters, composers
|
||||
that rewrap): `PredictiveEchoAddon`. It never withholds bytes, so the TUI
|
||||
behaves exactly as with no addon at all; you just stop waiting for the RTT.
|
||||
- Both can be loaded on one terminal and toggled per session mode; that is
|
||||
exactly what Codeman does (buffer for Claude Code, predict for Codex).
|
||||
|
||||
---
|
||||
|
||||
## Integration patterns
|
||||
|
||||
### Buffered input (hold until Enter)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.0",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
@@ -37,7 +37,9 @@
|
||||
"ssh",
|
||||
"remote-terminal",
|
||||
"overlay",
|
||||
"addon"
|
||||
"addon",
|
||||
"predictive",
|
||||
"write-through"
|
||||
],
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/Ark0N/Codeman/tree/master/packages/xterm-zerolag-input#readme",
|
||||
@@ -50,6 +52,7 @@
|
||||
"directory": "packages/xterm-zerolag-input"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.5.0",
|
||||
|
||||
@@ -11,37 +11,36 @@ import type { XtermTerminal, CellDimensions } from './types.js';
|
||||
* unavailable.
|
||||
*/
|
||||
export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const t = terminal as any;
|
||||
const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0
|
||||
? devicePixelRatio : 1;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const t = terminal as any;
|
||||
const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
||||
|
||||
// Try v7+ public API first
|
||||
if (t.dimensions?.css?.cell) {
|
||||
const cellH = t.dimensions.css.cell.height;
|
||||
return {
|
||||
width: t.dimensions.css.cell.width,
|
||||
height: cellH,
|
||||
charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr,
|
||||
charHeight: (t.dimensions?.device?.char?.height ?? (cellH * dpr)) / dpr,
|
||||
};
|
||||
// Try v7+ public API first
|
||||
if (t.dimensions?.css?.cell) {
|
||||
const cellH = t.dimensions.css.cell.height;
|
||||
return {
|
||||
width: t.dimensions.css.cell.width,
|
||||
height: cellH,
|
||||
charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr,
|
||||
charHeight: (t.dimensions?.device?.char?.height ?? cellH * dpr) / dpr,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to v5 private API
|
||||
try {
|
||||
const dims = t._core?._renderService?.dimensions;
|
||||
if (dims?.css?.cell) {
|
||||
const cellH = dims.css.cell.height;
|
||||
return {
|
||||
width: dims.css.cell.width,
|
||||
height: cellH,
|
||||
charTop: (dims.device?.char?.top ?? 0) / dpr,
|
||||
charHeight: (dims.device?.char?.height ?? cellH * dpr) / dpr,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Private API may throw in some environments
|
||||
}
|
||||
|
||||
// Fall back to v5 private API
|
||||
try {
|
||||
const dims = t._core?._renderService?.dimensions;
|
||||
if (dims?.css?.cell) {
|
||||
const cellH = dims.css.cell.height;
|
||||
return {
|
||||
width: dims.css.cell.width,
|
||||
height: cellH,
|
||||
charTop: (dims.device?.char?.top ?? 0) / dpr,
|
||||
charHeight: (dims.device?.char?.height ?? (cellH * dpr)) / dpr,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Private API may throw in some environments
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export { ZerolagInputAddon } from './zerolag-input-addon.js';
|
||||
export { PredictiveEchoAddon } from './predictive-echo-addon.js';
|
||||
export { charCellWidth, stringCellWidth } from './overlay-renderer.js';
|
||||
export type {
|
||||
XtermTerminal,
|
||||
XtermAddon,
|
||||
ZerolagInputOptions,
|
||||
ZerolagInputState,
|
||||
PromptFinder,
|
||||
PromptPosition,
|
||||
CellDimensions,
|
||||
XtermTerminal,
|
||||
XtermAddon,
|
||||
ZerolagInputOptions,
|
||||
ZerolagInputState,
|
||||
PromptFinder,
|
||||
PromptPosition,
|
||||
CellDimensions,
|
||||
} from './types.js';
|
||||
export type { PredictiveEchoOptions, PredictionState } from './predictive-echo-addon.js';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Incremental DOM renderer for PredictiveEchoAddon.
|
||||
*
|
||||
* Unlike overlay-renderer.ts (which paints whole lines with an opaque
|
||||
* background out to totalCols), prediction spans cover ONLY the predicted
|
||||
* glyph's own cells: anything wider would blank real echo arriving around
|
||||
* a prediction. Spans are keyed by prediction seq for O(1) removal.
|
||||
*/
|
||||
import type { CellDimensions, FontStyle } from './types.js';
|
||||
|
||||
export interface PredictionSpanParams {
|
||||
seq: number;
|
||||
/** Viewport-relative row (0-based). */
|
||||
row: number;
|
||||
/** Column (0-based). */
|
||||
col: number;
|
||||
char: string;
|
||||
/** Cell width of the glyph (1 or 2). */
|
||||
width: 1 | 2;
|
||||
dims: CellDimensions;
|
||||
font: FontStyle;
|
||||
underline: boolean;
|
||||
}
|
||||
|
||||
export function addPredictionSpan(
|
||||
container: HTMLElement,
|
||||
map: Map<number, HTMLSpanElement>,
|
||||
p: PredictionSpanParams
|
||||
): void {
|
||||
const span = document.createElement('span');
|
||||
// cellH+1 height: covers the sub-pixel seam between rows (same trick the
|
||||
// buffer overlay renderer ships with). Background covers only this glyph's
|
||||
// cells, never a full row.
|
||||
span.style.cssText =
|
||||
`position:absolute;left:${p.col * p.dims.width}px;top:${p.row * p.dims.height}px;` +
|
||||
`width:${p.width * p.dims.width}px;height:${p.dims.height + 1}px;line-height:${p.dims.height}px;` +
|
||||
`text-align:center;pointer-events:none;` +
|
||||
`font-family:${p.font.fontFamily};font-size:${p.font.fontSize};font-weight:${p.font.fontWeight};` +
|
||||
(p.font.letterSpacing ? `letter-spacing:${p.font.letterSpacing};` : '') +
|
||||
`color:${p.font.color};background-color:${p.font.backgroundColor};` +
|
||||
`font-feature-settings:'liga' 0,'calt' 0;` +
|
||||
(p.underline ? 'text-decoration:underline;' : '');
|
||||
span.textContent = p.char;
|
||||
map.set(p.seq, span);
|
||||
container.appendChild(span);
|
||||
}
|
||||
|
||||
export function removePredictionSpan(map: Map<number, HTMLSpanElement>, seq: number): void {
|
||||
const span = map.get(seq);
|
||||
if (span) {
|
||||
span.remove();
|
||||
map.delete(seq);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllSpans(map: Map<number, HTMLSpanElement>): void {
|
||||
for (const span of map.values()) span.remove();
|
||||
map.clear();
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* PredictiveEchoAddon: mosh-style write-through local echo.
|
||||
*
|
||||
* The consumer sends every keystroke to the PTY unchanged (write-through);
|
||||
* this addon simultaneously paints the predicted glyph at the predicted cell.
|
||||
* When the real echo lands, the prediction is confirmed and its span removed
|
||||
* (an invisible swap: identical glyph beneath). Mispredictions self-heal via
|
||||
* a mismatch cascade and a TTL. Everything here is visual-only: no method
|
||||
* gates, delays, or rewrites what the consumer sends.
|
||||
*
|
||||
* Reconciliation reads the parsed terminal BUFFER (cells after xterm's parser
|
||||
* ran), never the raw output stream. Full-line redraws, ECH-based gap
|
||||
* painting, and tmux's in-place deltas all converge to the same cells; stream
|
||||
* matching cannot survive them (see docs/local-echo-overlay-plan.md's
|
||||
* "What NOT to Do" in the consuming repo).
|
||||
*
|
||||
* Coordinate base: xterm's `cursorY` is relative to `baseY`, so the absolute
|
||||
* buffer line for a viewport row is `baseY + row`. `viewportY` would only
|
||||
* coincide while scrolled to the bottom; this file never relies on that.
|
||||
*/
|
||||
import { getCellDimensions } from './cell-dimensions.js';
|
||||
import { charCellWidth } from './overlay-renderer.js';
|
||||
import { addPredictionSpan, clearAllSpans, removePredictionSpan } from './prediction-renderer.js';
|
||||
import type { FontStyle, XtermAddon, XtermTerminal } from './types.js';
|
||||
|
||||
export interface PredictiveEchoOptions {
|
||||
/** Z-index of the span container. @default 7 (same layer as the buffer overlay) */
|
||||
zIndex?: number;
|
||||
/** Render predicted glyphs underlined (visual hedge on unreliable links). @default false */
|
||||
underlinePredictions?: boolean;
|
||||
/** Predicted glyph color. @default theme foreground / computed .xterm-rows color */
|
||||
foregroundColor?: string;
|
||||
/** Predicted glyph background. @default theme background */
|
||||
backgroundColor?: string;
|
||||
/** Drop predictions older than this. @default 1000 */
|
||||
ttlMs?: number;
|
||||
/** Maximum outstanding predictions per run. @default 32 */
|
||||
maxPending?: number;
|
||||
/** How long the cursor may sit off the anchor row before predictions clear. @default 150 */
|
||||
cursorGraceMs?: number;
|
||||
/** Suppress predictions that would land within this many cells of the right edge. @default 4 */
|
||||
edgeMarginCells?: number;
|
||||
/** Gate: return false to suppress prediction (e.g. cursor not on a composer row). */
|
||||
predictWhen?: (terminal: XtermTerminal) => boolean;
|
||||
}
|
||||
|
||||
export interface PredictionState {
|
||||
outstanding: number;
|
||||
confirmedTotal: number;
|
||||
droppedTotal: number;
|
||||
anchor: { row: number; col: number } | null;
|
||||
}
|
||||
|
||||
interface PredictionRecord {
|
||||
seq: number;
|
||||
char: string;
|
||||
/** Cells this glyph occupies. */
|
||||
width: 1 | 2;
|
||||
/** Cumulative cell offset from the anchor column BEFORE this char. */
|
||||
offsetCells: number;
|
||||
/** Cell content at predict time, '' normalized to ' '. */
|
||||
snapshot: string;
|
||||
sentAt: number;
|
||||
/** Consecutive reconcile passes that saw foreign non-blank content. */
|
||||
mismatches: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
zIndex: 7,
|
||||
underlinePredictions: false,
|
||||
ttlMs: 1000,
|
||||
maxPending: 32,
|
||||
cursorGraceMs: 150,
|
||||
edgeMarginCells: 4,
|
||||
} as const;
|
||||
|
||||
const DEFAULT_BG = '#000000';
|
||||
const DEFAULT_FG = '#ffffff';
|
||||
|
||||
export class PredictiveEchoAddon implements XtermAddon {
|
||||
private _terminal: XtermTerminal | null = null;
|
||||
private _container: HTMLDivElement | null = null;
|
||||
private _spans = new Map<number, HTMLSpanElement>();
|
||||
private _outstanding: PredictionRecord[] = [];
|
||||
private _anchor: { row: number; col: number } | null = null;
|
||||
private _cursorOffRowSince: number | null = null;
|
||||
private _seq = 0;
|
||||
private _confirmedTotal = 0;
|
||||
private _droppedTotal = 0;
|
||||
private _ttlTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Anchor hold: set after an unpredicted wire edit (backspace into echoed
|
||||
* text, any cleared input, an IME text commit). While held, new
|
||||
* predictions are suppressed: the displayed cursor is stale until the
|
||||
* next parsed write, and anchoring on it paints ghosts one cell off
|
||||
* (found by review: backspace-then-retype within RTT). Cleared by the
|
||||
* onWriteParsed pass and by public reconcile(), never by the inline
|
||||
* predictChar pass (which runs before the display could catch up). */
|
||||
private _anchorHold = false;
|
||||
private _reconcileScheduled = false;
|
||||
private _disposables: Array<{ dispose(): void }> = [];
|
||||
private _predictWhen: ((terminal: XtermTerminal) => boolean) | null;
|
||||
private _options: Required<Omit<PredictiveEchoOptions, 'foregroundColor' | 'backgroundColor' | 'predictWhen'>> &
|
||||
Pick<PredictiveEchoOptions, 'foregroundColor' | 'backgroundColor'>;
|
||||
private _font: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: DEFAULT_FG,
|
||||
backgroundColor: DEFAULT_BG,
|
||||
letterSpacing: '',
|
||||
};
|
||||
|
||||
constructor(options?: PredictiveEchoOptions) {
|
||||
this._options = {
|
||||
zIndex: options?.zIndex ?? DEFAULT_OPTIONS.zIndex,
|
||||
underlinePredictions: options?.underlinePredictions ?? DEFAULT_OPTIONS.underlinePredictions,
|
||||
ttlMs: options?.ttlMs ?? DEFAULT_OPTIONS.ttlMs,
|
||||
maxPending: options?.maxPending ?? DEFAULT_OPTIONS.maxPending,
|
||||
cursorGraceMs: options?.cursorGraceMs ?? DEFAULT_OPTIONS.cursorGraceMs,
|
||||
edgeMarginCells: options?.edgeMarginCells ?? DEFAULT_OPTIONS.edgeMarginCells,
|
||||
foregroundColor: options?.foregroundColor,
|
||||
backgroundColor: options?.backgroundColor,
|
||||
};
|
||||
this._predictWhen = options?.predictWhen ?? null;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
/** Called by `terminal.loadAddon()`. Do not call directly. */
|
||||
activate(terminal: XtermTerminal): void {
|
||||
this._terminal = terminal;
|
||||
|
||||
this._container = document.createElement('div');
|
||||
this._container.setAttribute('data-predictive-echo', '');
|
||||
this._container.style.cssText = `position:absolute;left:0;top:0;z-index:${this._options.zIndex};pointer-events:none`;
|
||||
const screen = terminal.element?.querySelector('.xterm-screen');
|
||||
if (screen) screen.appendChild(this._container);
|
||||
|
||||
this._readFontStyle();
|
||||
|
||||
// Debounced post-parse reconcile: xterm fires onWriteParsed after the
|
||||
// parser finishes a write chunk, so buffer reads see consistent state.
|
||||
// The microtask coalesces multi-chunk bursts into one pass.
|
||||
if (typeof terminal.onWriteParsed === 'function') {
|
||||
try {
|
||||
this._disposables.push(
|
||||
terminal.onWriteParsed(() => {
|
||||
if (this._reconcileScheduled) return;
|
||||
this._reconcileScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this._reconcileScheduled = false;
|
||||
this._anchorHold = false; // a parse pass ran: the display caught up
|
||||
this._safeReconcile();
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
/* consumers without a working emitter fall back to manual reconcile() */
|
||||
}
|
||||
}
|
||||
if (typeof terminal.onResize === 'function') {
|
||||
try {
|
||||
this._disposables.push(terminal.onResize(() => this.clearPredictions()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearPredictions();
|
||||
for (const d of this._disposables) {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this._disposables = [];
|
||||
this._container?.remove();
|
||||
this._container = null;
|
||||
this._terminal = null;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Predict a single typed character at the current insertion point.
|
||||
* Returns false when suppressed; the consumer sends the keystroke to the
|
||||
* PTY either way (the return value is informational, never a send gate).
|
||||
*/
|
||||
predictChar(ch: string): boolean {
|
||||
try {
|
||||
this._reconcile();
|
||||
if (this._anchorHold) return false; // display has not caught up with a wire edit
|
||||
|
||||
const t = this._terminal;
|
||||
if (!t || !this._container) return false;
|
||||
const dims = getCellDimensions(t);
|
||||
if (!dims) return false;
|
||||
const buf = t.buffer.active;
|
||||
if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return false;
|
||||
if (buf.viewportY !== buf.baseY) return false;
|
||||
if (this._predictWhen && this._predictWhen(t) === false) return false;
|
||||
|
||||
const cps = Array.from(ch);
|
||||
if (cps.length !== 1) return false;
|
||||
const cp = cps[0].codePointAt(0)!;
|
||||
if (cp < 0x20 || cp === 0x7f) return false;
|
||||
const w = charCellWidth(t, cps[0]);
|
||||
if (w !== 1 && w !== 2) return false;
|
||||
if (w === 2 && !this._hasGetCell()) return false; // ASCII fallback misaligns on wide cols
|
||||
if (this._outstanding.length >= this._options.maxPending) return false;
|
||||
|
||||
if (this._outstanding.length === 0) {
|
||||
this._anchor = { row: buf.cursorY, col: buf.cursorX };
|
||||
this._cursorOffRowSince = null;
|
||||
}
|
||||
const anchor = this._anchor!;
|
||||
const last = this._outstanding[this._outstanding.length - 1];
|
||||
const offset = last ? last.offsetCells + last.width : 0;
|
||||
const col = anchor.col + offset;
|
||||
if (col + w > t.cols - this._options.edgeMarginCells) return false;
|
||||
|
||||
const rec: PredictionRecord = {
|
||||
seq: this._seq++,
|
||||
char: cps[0],
|
||||
width: w,
|
||||
offsetCells: offset,
|
||||
snapshot: this._readCell(anchor.row, col),
|
||||
sentAt: performance.now(),
|
||||
mismatches: 0,
|
||||
};
|
||||
this._outstanding.push(rec);
|
||||
addPredictionSpan(this._container, this._spans, {
|
||||
seq: rec.seq,
|
||||
row: anchor.row,
|
||||
col,
|
||||
char: rec.char,
|
||||
width: w,
|
||||
dims,
|
||||
font: this._font,
|
||||
underline: this._options.underlinePredictions,
|
||||
});
|
||||
this._armTtl();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the newest outstanding prediction (visual only). Returns false when
|
||||
* none are outstanding. The consumer forwards \x7f UNCONDITIONALLY either
|
||||
* way; deleting already-echoed text renders at RTT.
|
||||
*/
|
||||
predictBackspace(): boolean {
|
||||
try {
|
||||
const rec = this._outstanding.pop();
|
||||
if (!rec) {
|
||||
// \x7f goes to the wire and will delete ECHOED text: the cursor is
|
||||
// about to move in a way we cannot see yet
|
||||
this._anchorHold = true;
|
||||
return false;
|
||||
}
|
||||
removePredictionSpan(this._spans, rec.seq);
|
||||
if (this._outstanding.length === 0) this._resetRun();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every outstanding prediction and its spans. Also arms the anchor
|
||||
* hold: consumers clear on inputs (Enter, Esc, arrows, pastes) whose
|
||||
* cursor effect is unknown until the next parsed write. */
|
||||
clearPredictions(): void {
|
||||
try {
|
||||
this._anchorHold = true;
|
||||
this._droppedTotal += this._outstanding.length;
|
||||
this._outstanding = [];
|
||||
clearAllSpans(this._spans);
|
||||
this._resetRun();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual reconcile pass, for consumers without onWriteParsed. By contract
|
||||
* it is called after writes parsed, so it also releases the anchor hold. */
|
||||
reconcile(): void {
|
||||
this._anchorHold = false;
|
||||
this._safeReconcile();
|
||||
}
|
||||
|
||||
/** Swap the prediction gate at runtime (mirrors the buffer addon's setPrompt). */
|
||||
setPredictWhen(fn: ((terminal: XtermTerminal) => boolean) | null): void {
|
||||
this._predictWhen = fn;
|
||||
}
|
||||
|
||||
/** Re-read font/theme (call after skin or font-size changes). */
|
||||
refreshFont(): void {
|
||||
this._readFontStyle();
|
||||
}
|
||||
|
||||
get hasPredictions(): boolean {
|
||||
return this._outstanding.length > 0;
|
||||
}
|
||||
|
||||
get state(): PredictionState {
|
||||
return {
|
||||
outstanding: this._outstanding.length,
|
||||
confirmedTotal: this._confirmedTotal,
|
||||
droppedTotal: this._droppedTotal,
|
||||
anchor: this._anchor ? { ...this._anchor } : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reconciliation ───────────────────────────────────────────────
|
||||
|
||||
private _safeReconcile(): void {
|
||||
try {
|
||||
this._reconcile();
|
||||
} catch {
|
||||
/* predictions may degrade, never break input */
|
||||
}
|
||||
}
|
||||
|
||||
private _reconcile(): void {
|
||||
const t = this._terminal;
|
||||
if (!t) return;
|
||||
if (this._outstanding.length === 0) return; // streaming cost: one boolean
|
||||
const buf = t.buffer.active;
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
this.clearPredictions(); // user scrolled up
|
||||
return;
|
||||
}
|
||||
if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return; // TTL will clean
|
||||
const anchor = this._anchor!;
|
||||
const now = performance.now();
|
||||
|
||||
// Off-row grace: transient cursor excursions (repaints park the cursor
|
||||
// elsewhere mid-frame) are tolerated; a sustained move means the composer
|
||||
// relocated or the user navigated, so predictions are stale.
|
||||
if (buf.cursorY !== anchor.row) {
|
||||
this._cursorOffRowSince ??= now;
|
||||
if (now - this._cursorOffRowSince > this._options.cursorGraceMs) {
|
||||
this.clearPredictions();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this._cursorOffRowSince = null;
|
||||
}
|
||||
|
||||
// Confirm loop: PREFIX-ONLY, and only with the cursor advanced past the
|
||||
// record. Cell match alone is not enough: the predicted char may equal
|
||||
// pre-existing content (placeholder glyphs), and an identical in-place
|
||||
// tmux repaint must be a no-op (cells match snapshots, cursor unmoved).
|
||||
while (this._outstanding.length > 0) {
|
||||
const rec = this._outstanding[0];
|
||||
const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells);
|
||||
if (cell === rec.char && buf.cursorY === anchor.row && buf.cursorX >= anchor.col + rec.offsetCells + rec.width) {
|
||||
this._outstanding.shift();
|
||||
removePredictionSpan(this._spans, rec.seq);
|
||||
this._confirmedTotal++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Mismatch scan (two-pass rule): a half-parsed row on pass N is fully
|
||||
// redrawn a few ms later, so only content foreign on TWO consecutive
|
||||
// passes cascades. Blank cells are NEUTRAL, not foreign: codex clears its
|
||||
// placeholder on the first echo, and the blanks left under later
|
||||
// predictions are what "not yet echoed" looks like, not evidence of a
|
||||
// redraw (measured 2026-08-09; without this, fast typing over the
|
||||
// placeholder cascades exactly when RTT is high). TTL still bounds them.
|
||||
let dropFrom = -1;
|
||||
for (let i = 0; i < this._outstanding.length; i++) {
|
||||
const rec = this._outstanding[i];
|
||||
const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells);
|
||||
if (cell !== rec.snapshot && cell !== rec.char && cell !== ' ') {
|
||||
rec.mismatches++;
|
||||
if (rec.mismatches >= 2) {
|
||||
dropFrom = i;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
rec.mismatches = 0;
|
||||
}
|
||||
}
|
||||
if (dropFrom !== -1) this._dropFrom(dropFrom);
|
||||
|
||||
// TTL: the first stale record drops itself and everything after it.
|
||||
for (let i = 0; i < this._outstanding.length; i++) {
|
||||
if (now - this._outstanding[i].sentAt > this._options.ttlMs) {
|
||||
this._dropFrom(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._outstanding.length === 0) {
|
||||
this._resetRun();
|
||||
} else {
|
||||
this._armTtl();
|
||||
}
|
||||
}
|
||||
|
||||
private _dropFrom(index: number): void {
|
||||
const dropped = this._outstanding.splice(index);
|
||||
for (const rec of dropped) removePredictionSpan(this._spans, rec.seq);
|
||||
this._droppedTotal += dropped.length;
|
||||
}
|
||||
|
||||
private _resetRun(): void {
|
||||
this._anchor = null;
|
||||
this._cursorOffRowSince = null;
|
||||
if (this._ttlTimer !== null) {
|
||||
clearTimeout(this._ttlTimer);
|
||||
this._ttlTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _armTtl(): void {
|
||||
if (this._ttlTimer !== null) return;
|
||||
const oldest = this._outstanding[0];
|
||||
if (!oldest) return;
|
||||
const delay = Math.max(0, oldest.sentAt + this._options.ttlMs - performance.now()) + 1;
|
||||
this._ttlTimer = setTimeout(() => {
|
||||
this._ttlTimer = null;
|
||||
this._safeReconcile();
|
||||
this._armTtl();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ─── Cell access ──────────────────────────────────────────────────
|
||||
|
||||
private _hasGetCell(): boolean {
|
||||
const buf = this._terminal?.buffer.active;
|
||||
if (!buf) return false;
|
||||
const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0));
|
||||
return typeof line?.getCell === 'function';
|
||||
}
|
||||
|
||||
/** Read one cell's chars at (viewport-relative row, col); '' -> ' '. */
|
||||
private _readCell(row: number, col: number): string {
|
||||
const buf = this._terminal!.buffer.active;
|
||||
const line = buf.getLine(buf.baseY + row);
|
||||
if (!line) return ' ';
|
||||
if (typeof line.getCell === 'function') {
|
||||
const chars = line.getCell(col)?.getChars() ?? '';
|
||||
return chars === '' ? ' ' : chars;
|
||||
}
|
||||
// ASCII fallback: code-unit index, misaligns after wide columns, which is
|
||||
// why width-2 predictions are suppressed without getCell.
|
||||
const text = line.translateToString(true);
|
||||
return text[col] ?? ' ';
|
||||
}
|
||||
|
||||
// ─── Font ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Same recipe as the buffer addon's _cacheFont (kept private on purpose:
|
||||
* zerolag-input-addon.ts must stay untouched by this feature). */
|
||||
private _readFontStyle(): void {
|
||||
const t = this._terminal;
|
||||
if (!t) return;
|
||||
this._font.fontFamily = t.options.fontFamily || 'monospace';
|
||||
this._font.fontSize = (t.options.fontSize || 14) + 'px';
|
||||
this._font.fontWeight = String(t.options.fontWeight || 'normal');
|
||||
this._font.backgroundColor = this._options.backgroundColor ?? t.options.theme?.background ?? DEFAULT_BG;
|
||||
this._font.color = this._options.foregroundColor ?? t.options.theme?.foreground ?? DEFAULT_FG;
|
||||
this._font.letterSpacing = '';
|
||||
const rows = t.element?.querySelector('.xterm-rows');
|
||||
if (rows) {
|
||||
const cs = getComputedStyle(rows);
|
||||
this._font.letterSpacing = cs.letterSpacing;
|
||||
if (!this._options.foregroundColor && cs.color) this._font.color = cs.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,55 +6,50 @@ import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js';
|
||||
*
|
||||
* @returns The prompt position (viewport-relative), or `null` if not found.
|
||||
*/
|
||||
export function findPrompt(
|
||||
terminal: XtermTerminal,
|
||||
finder: PromptFinder,
|
||||
): PromptPosition | null {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const viewportTop = buffer.viewportY;
|
||||
export function findPrompt(terminal: XtermTerminal, finder: PromptFinder): PromptPosition | null {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const viewportTop = buffer.viewportY;
|
||||
|
||||
switch (finder.type) {
|
||||
case 'character': {
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf(finder.char);
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
// Create a fresh non-global regex to avoid lastIndex mutation
|
||||
// and ensure .match() returns a single result with .index
|
||||
const pattern = finder.pattern;
|
||||
const safePattern = pattern.global
|
||||
? new RegExp(pattern.source, pattern.flags.replace('g', ''))
|
||||
: pattern;
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const match = text.match(safePattern);
|
||||
if (match) {
|
||||
const col = match.index ?? 0;
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
return finder.find(terminal);
|
||||
|
||||
default:
|
||||
return null;
|
||||
switch (finder.type) {
|
||||
case 'character': {
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf(finder.char);
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
// Create a fresh non-global regex to avoid lastIndex mutation
|
||||
// and ensure .match() returns a single result with .index
|
||||
const pattern = finder.pattern;
|
||||
const safePattern = pattern.global ? new RegExp(pattern.source, pattern.flags.replace('g', '')) : pattern;
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const match = text.match(safePattern);
|
||||
if (match) {
|
||||
const col = match.index ?? 0;
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
return finder.find(terminal);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,19 +60,15 @@ export function findPrompt(
|
||||
* @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ")
|
||||
* @returns The text after the prompt, trimmed. Empty string if nothing found.
|
||||
*/
|
||||
export function readTextAfterPrompt(
|
||||
terminal: XtermTerminal,
|
||||
prompt: PromptPosition,
|
||||
offset: number,
|
||||
): string {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const absRow = buffer.viewportY + prompt.row;
|
||||
const line = buffer.getLine(absRow);
|
||||
if (!line) return '';
|
||||
const lineText = line.translateToString(true);
|
||||
return lineText.slice(prompt.col + offset).trimEnd();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosition, offset: number): string {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const absRow = buffer.viewportY + prompt.row;
|
||||
const line = buffer.getLine(absRow);
|
||||
if (!line) return '';
|
||||
const lineText = line.translateToString(true);
|
||||
return lineText.slice(prompt.col + offset).trimEnd();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,15 @@ export interface XtermTerminal {
|
||||
readonly active: {
|
||||
readonly viewportY: number;
|
||||
readonly baseY: number;
|
||||
/** Cursor column (0-based). Used by PredictiveEchoAddon. */
|
||||
readonly cursorX?: number;
|
||||
/** Cursor row, relative to baseY (0-based). Used by PredictiveEchoAddon. */
|
||||
readonly cursorY?: number;
|
||||
getLine(y: number):
|
||||
| {
|
||||
translateToString(trimRight?: boolean): string;
|
||||
/** Cell access (xterm public API). Optional: mocks/exotic hosts may omit it. */
|
||||
getCell?(x: number): { getChars(): string; getWidth(): number } | undefined;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
@@ -34,6 +40,10 @@ export interface XtermTerminal {
|
||||
getStringCellWidth(str: string): number;
|
||||
activeVersion?: string;
|
||||
};
|
||||
/** Fires after the parser finishes a write chunk. Used by PredictiveEchoAddon. */
|
||||
onWriteParsed?(cb: () => void): { dispose(): void };
|
||||
/** Fires on terminal resize. Used by PredictiveEchoAddon. */
|
||||
onResize?(cb: (size: { cols: number; rows: number }) => void): { dispose(): void };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,122 +6,125 @@ import type { XtermTerminal } from '../src/types.js';
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups = [];
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe('getCellDimensions', () => {
|
||||
describe('v5 private API (mock _core._renderService)', () => {
|
||||
it('returns cell width and height from css.cell', () => {
|
||||
const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 });
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
expect(dims!.width).toBe(8.4);
|
||||
expect(dims!.height).toBe(19);
|
||||
});
|
||||
|
||||
it('returns charTop from device.char.top divided by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 8, cellHeight: 19,
|
||||
deviceCharTop: 2,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
// DPR=1 in jsdom, so charTop = 2 / 1 = 2
|
||||
expect(dims!.charTop).toBe(2);
|
||||
});
|
||||
|
||||
it('returns charHeight from device.char.height divided by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 8, cellHeight: 19,
|
||||
deviceCharHeight: 16,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
// DPR=1, so charHeight = 16 / 1 = 16
|
||||
expect(dims!.charHeight).toBe(16);
|
||||
});
|
||||
|
||||
it('defaults charTop to 0 when device.char not present', () => {
|
||||
// Default mock has deviceCharTop=0
|
||||
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims!.charTop).toBe(0);
|
||||
});
|
||||
|
||||
it('defaults charHeight to cellH when device.char.height not set', () => {
|
||||
// Default mock has deviceCharHeight=cellH
|
||||
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims!.charHeight).toBe(19);
|
||||
});
|
||||
describe('v5 private API (mock _core._renderService)', () => {
|
||||
it('returns cell width and height from css.cell', () => {
|
||||
const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 });
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
expect(dims!.width).toBe(8.4);
|
||||
expect(dims!.height).toBe(19);
|
||||
});
|
||||
|
||||
describe('DPR simulation', () => {
|
||||
const originalDPR = globalThis.devicePixelRatio;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set DPR=2 to test division
|
||||
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
||||
value: 2,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
||||
value: originalDPR,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('divides device.char.top by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 16, cellHeight: 38,
|
||||
deviceCharTop: 4,
|
||||
deviceCharHeight: 32,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
// charTop = 4 / 2 = 2
|
||||
expect(dims!.charTop).toBe(2);
|
||||
// charHeight = 32 / 2 = 16
|
||||
expect(dims!.charHeight).toBe(16);
|
||||
});
|
||||
it('returns charTop from device.char.top divided by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 8,
|
||||
cellHeight: 19,
|
||||
deviceCharTop: 2,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
// DPR=1 in jsdom, so charTop = 2 / 1 = 2
|
||||
expect(dims!.charTop).toBe(2);
|
||||
});
|
||||
|
||||
describe('null cases', () => {
|
||||
it('returns null for terminal without _core', () => {
|
||||
const terminal = {
|
||||
element: document.createElement('div'),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {},
|
||||
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
||||
} as unknown as XtermTerminal;
|
||||
const dims = getCellDimensions(terminal);
|
||||
expect(dims).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for terminal with no dimensions', () => {
|
||||
const terminal = {
|
||||
element: document.createElement('div'),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {},
|
||||
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
||||
_core: { _renderService: {} },
|
||||
} as unknown as XtermTerminal;
|
||||
const dims = getCellDimensions(terminal);
|
||||
expect(dims).toBeNull();
|
||||
});
|
||||
it('returns charHeight from device.char.height divided by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 8,
|
||||
cellHeight: 19,
|
||||
deviceCharHeight: 16,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
// DPR=1, so charHeight = 16 / 1 = 16
|
||||
expect(dims!.charHeight).toBe(16);
|
||||
});
|
||||
|
||||
it('defaults charTop to 0 when device.char not present', () => {
|
||||
// Default mock has deviceCharTop=0
|
||||
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims!.charTop).toBe(0);
|
||||
});
|
||||
|
||||
it('defaults charHeight to cellH when device.char.height not set', () => {
|
||||
// Default mock has deviceCharHeight=cellH
|
||||
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims!.charHeight).toBe(19);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DPR simulation', () => {
|
||||
const originalDPR = globalThis.devicePixelRatio;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set DPR=2 to test division
|
||||
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
||||
value: 2,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
||||
value: originalDPR,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('divides device.char.top by DPR', () => {
|
||||
const mock = createMockTerminal({
|
||||
cellWidth: 16,
|
||||
cellHeight: 38,
|
||||
deviceCharTop: 4,
|
||||
deviceCharHeight: 32,
|
||||
});
|
||||
cleanups.push(mock.cleanup);
|
||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||
expect(dims).not.toBeNull();
|
||||
// charTop = 4 / 2 = 2
|
||||
expect(dims!.charTop).toBe(2);
|
||||
// charHeight = 32 / 2 = 16
|
||||
expect(dims!.charHeight).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('null cases', () => {
|
||||
it('returns null for terminal without _core', () => {
|
||||
const terminal = {
|
||||
element: document.createElement('div'),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {},
|
||||
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
||||
} as unknown as XtermTerminal;
|
||||
const dims = getCellDimensions(terminal);
|
||||
expect(dims).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for terminal with no dimensions', () => {
|
||||
const terminal = {
|
||||
element: document.createElement('div'),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {},
|
||||
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
||||
_core: { _renderService: {} },
|
||||
} as unknown as XtermTerminal;
|
||||
const dims = getCellDimensions(terminal);
|
||||
expect(dims).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Layer 2 (the load-bearing suite): the REAL algorithm against the REAL xterm
|
||||
* parser, fed by fixtures recorded from real codex 0.147 through the
|
||||
* production pipeline (tmux + the codex full strip). See
|
||||
* scripts/dev/record-codex-frames.mjs in the consuming repo.
|
||||
*
|
||||
* Every replay ends with the convergence invariant: predictions never outlive
|
||||
* their run (outstanding 0, span container empty).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js';
|
||||
import {
|
||||
CELL_H,
|
||||
CELL_W,
|
||||
classifyPredictInput,
|
||||
codexComposerGate,
|
||||
createReplayTerminal,
|
||||
loadFixture,
|
||||
type ReplayTerminal,
|
||||
} from './replay-helpers.js';
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
interface KeyEvent {
|
||||
key: string;
|
||||
kind: ReturnType<typeof classifyPredictInput>;
|
||||
painted: boolean;
|
||||
spansAfter: number;
|
||||
}
|
||||
|
||||
function assertSpansInGrid(rt: ReplayTerminal) {
|
||||
for (const s of rt.spans()) {
|
||||
const left = parseFloat(s.style.left);
|
||||
const width = parseFloat(s.style.width);
|
||||
const top = parseFloat(s.style.top);
|
||||
expect(left + width).toBeLessThanOrEqual(rt.hybrid.cols * CELL_W);
|
||||
expect(top).toBeLessThanOrEqual((rt.hybrid.rows - 1) * CELL_H);
|
||||
expect(left).toBeGreaterThanOrEqual(0);
|
||||
expect(top).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function replay(name: string) {
|
||||
const { meta, lines } = loadFixture(name);
|
||||
const rt = createReplayTerminal(meta.cols, meta.rows);
|
||||
const addon = new PredictiveEchoAddon({ predictWhen: codexComposerGate });
|
||||
addon.activate(rt.hybrid);
|
||||
|
||||
const events: KeyEvent[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.keyAt) {
|
||||
const kind = classifyPredictInput(line.data);
|
||||
let painted = false;
|
||||
if (kind === 'char') painted = addon.predictChar(line.data);
|
||||
else if (kind === 'backspace') addon.predictBackspace();
|
||||
else addon.clearPredictions(); // 'clear' AND 'text', like the terminal-ui hook
|
||||
// Span/record parity and grid bounds hold at every step
|
||||
expect(rt.spanCount()).toBe(addon.state.outstanding);
|
||||
assertSpansInGrid(rt);
|
||||
events.push({ key: line.data, kind, painted, spansAfter: rt.spanCount() });
|
||||
} else {
|
||||
await rt.write(line.data);
|
||||
await flushMicrotasks();
|
||||
}
|
||||
}
|
||||
return { rt, addon, events, meta };
|
||||
}
|
||||
|
||||
/** Convergence invariant: after the last chunk + reconcile (+ TTL if needed),
|
||||
* nothing outlives the run. */
|
||||
async function converge(rt: ReplayTerminal, addon: PredictiveEchoAddon) {
|
||||
addon.reconcile();
|
||||
if (addon.state.outstanding > 0) {
|
||||
await sleep(1100); // ttlMs default
|
||||
addon.reconcile();
|
||||
}
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(rt.spanCount()).toBe(0);
|
||||
}
|
||||
|
||||
describe('codex replay', () => {
|
||||
it('type-hello: all 5 predictions confirm, zero drops, composer converges', async () => {
|
||||
const { rt, addon, events } = await replay('type-hello');
|
||||
const chars = events.filter((e) => e.kind === 'char');
|
||||
expect(chars).toHaveLength(5);
|
||||
expect(chars.every((e) => e.painted)).toBe(true);
|
||||
await converge(rt, addon);
|
||||
expect(addon.state.confirmedTotal).toBe(5);
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
expect(rt.cursorRowText()).toBe('› hello');
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
|
||||
it('slash-picker: "/" and filter chars confirm; no ghosts while picker rows redraw', async () => {
|
||||
const { rt, addon, events } = await replay('slash-picker');
|
||||
const chars = events.filter((e) => e.kind === 'char');
|
||||
expect(chars.map((e) => e.key)).toEqual(['/', 'm', 'o']);
|
||||
expect(chars.every((e) => e.painted)).toBe(true);
|
||||
await converge(rt, addon);
|
||||
expect(addon.state.confirmedTotal).toBe(3);
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
|
||||
it('wrap: predictions stay inside the grid, continuation rows fall back to real echo, buffer converges', async () => {
|
||||
const { rt, addon, events } = await replay('wrap');
|
||||
// The gate goes false once the cursor is on a wrapped continuation row
|
||||
// (2-space indent, no "› "): a tail of keystrokes must be suppressed.
|
||||
const chars = events.filter((e) => e.kind === 'char');
|
||||
expect(chars.some((e) => !e.painted)).toBe(true);
|
||||
expect(chars.some((e) => e.painted)).toBe(true);
|
||||
await converge(rt, addon);
|
||||
// The composer content is exactly what was typed (word-wrapped)
|
||||
const b = rt.term.buffer.active;
|
||||
const cursorRow = b.cursorY;
|
||||
expect(rt.rowText(cursorRow).trim()).toBe('this line twice over');
|
||||
expect(rt.rowText(cursorRow - 1)).toMatch(/^› the quick brown fox/);
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
|
||||
it('streaming-burst: typed predictions confirm; the re-rendered composer keeps its signature', async () => {
|
||||
const { rt, addon, events } = await replay('streaming-burst');
|
||||
const chars = events.filter((e) => e.kind === 'char');
|
||||
expect(chars).toHaveLength(5); // "hello" (the \r is kind 'clear')
|
||||
await converge(rt, addon);
|
||||
expect(addon.state.confirmedTotal).toBe(5);
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
// After the 401 burst codex re-renders a fresh composer at the cursor
|
||||
expect(rt.cursorRowText()).toMatch(/^› /);
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
|
||||
it('streaming-real: mid-stream typing survives real baseY growth (recorded with real auth)', async () => {
|
||||
// The one shape the fake-key lab cannot produce: a genuine model reply
|
||||
// streaming above the pinned composer pushes lines into history, so
|
||||
// baseY GROWS while predictions are outstanding: the no-drop-on-baseY
|
||||
// rule against reality instead of a synthetic scroll.
|
||||
const { rt, addon, events } = await replay('streaming-real');
|
||||
expect(rt.term.buffer.active.baseY).toBeGreaterThan(0); // history really grew
|
||||
const midStream = events.filter((e) => e.kind === 'char' && ['a', 'b', 'c'].includes(e.key));
|
||||
expect(midStream.length).toBe(3);
|
||||
expect(midStream.some((e) => e.painted)).toBe(true); // predictions ran mid-stream
|
||||
await converge(rt, addon);
|
||||
expect(rt.cursorRowText()).toBe('› abc'); // the mid-stream chars landed intact
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
|
||||
it('paste-bracketed: typed chars confirm, the paste clears predictions, content intact', async () => {
|
||||
const { rt, addon, events } = await replay('paste-bracketed');
|
||||
const paste = events.find((e) => e.key.startsWith('\x1b[200~'))!;
|
||||
expect(paste.kind).toBe('clear');
|
||||
expect(paste.spansAfter).toBe(0);
|
||||
await converge(rt, addon);
|
||||
expect(addon.state.confirmedTotal).toBe(2); // 'a', 'b'
|
||||
expect(rt.cursorRowText()).toContain('abXYZpasted');
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
|
||||
it('trust-modal: the predictWhen gate paints ZERO spans on the modal (ghost eliminator)', async () => {
|
||||
const { rt, addon, events } = await replay('trust-modal');
|
||||
const x = events.find((e) => e.key === 'x')!;
|
||||
expect(x.painted).toBe(false);
|
||||
expect(x.spansAfter).toBe(0);
|
||||
expect(events.every((e) => e.spansAfter === 0)).toBe(true);
|
||||
await converge(rt, addon);
|
||||
expect(addon.state.confirmedTotal).toBe(0);
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
// The transition landed on the real composer afterwards
|
||||
expect(rt.cursorRowText()).toMatch(/^› /);
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{"scenario":"paste-bracketed","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:11.762Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":45,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b(B\u001b[m$ "}
|
||||
{"delayMs":638,"data":"exec codex\r\n"}
|
||||
{"delayMs":420,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":182,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":5,"data":"\r\n\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[1;30r\u001b[4;1H\u001b(B\u001b[m"}
|
||||
{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mSummarize rec\u001b(B\u001b[m\u001b[2ment commits\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[14;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":7,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":21,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":159,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mSummarize recent commits\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[7;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":21,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;27H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[24C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[24C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":3487,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CSummarize recent commits\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||
{"keyAt":true,"data":"a"}
|
||||
{"delayMs":207,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||
{"keyAt":true,"data":"b"}
|
||||
{"delayMs":91,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||
{"keyAt":true,"data":"\u001b[200~XYZpasted\u001b[201~"}
|
||||
{"delayMs":383,"data":"XYZpasted\u001b[K\u001b[20;80H\u001b[K\u001b[18;14H"}
|
||||
@@ -0,0 +1,32 @@
|
||||
{"scenario":"slash-picker","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:42.069Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":37,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b(B\u001b[m$ "}
|
||||
{"delayMs":647,"data":"exec codex\r\n"}
|
||||
{"delayMs":437,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":183,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":8,"data":"\r\n\u001b[J\u001b[A\u001b[K\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b[39m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[1;30r\u001b[14;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":9,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":12,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":10,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":157,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[7;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":20,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;39H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":3476,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CUse /skills to list available skills\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||
{"keyAt":true,"data":"/"}
|
||||
{"delayMs":207,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[21;3H\u001b(B\u001b[m/fast\u001b[10C\u001b[2m1.5x speed, increased usage\u001b[22;3H\u001b(B\u001b[m/ide\u001b[11C\u001b[2minclude current selection, open files, and other context from your IDE\u001b[23;3H\u001b(B\u001b[m/permissions\u001b[3C\u001b[2mchoose what Codex is allowed to do\u001b[24;3H\u001b(B\u001b[m/keymap\u001b[8C\u001b[2mremap TUI shortcuts\u001b[25;3H\u001b(B\u001b[m/vim\u001b[11C\u001b[2mtoggle Vim mode for the composer\u001b[26;3H\u001b(B\u001b[m/experimental\u001b[2C\u001b[2mtoggle experimental features\u001b[27;3H\u001b(B\u001b[m/approve\u001b[7C\u001b[2mapprove one retry of a recent auto-review denial\u001b[18;4H\u001b(B\u001b[m"}
|
||||
{"keyAt":true,"data":"m"}
|
||||
{"delayMs":398,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/m\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[21;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[memories\u001b[2C\u001b[2mconfigure memory use and generation\u001b[22;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[mention\u001b[3C\u001b[2mmention a file\u001b[23;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[mcp\u001b[7C\u001b[2mlist configured MCP tools; use /mcp verbose for details\u001b[18;5H\u001b(B\u001b[m"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":148,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/mo\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[18;6H\u001b(B\u001b[m"}
|
||||
{"keyAt":true,"data":"\u001b"}
|
||||
@@ -0,0 +1,233 @@
|
||||
{"scenario":"streaming-burst","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:03.828Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":39,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b(B\u001b[m$ "}
|
||||
{"delayMs":635,"data":"exec codex\r\n"}
|
||||
{"delayMs":439,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":184,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":9,"data":"\r\n\u001b[J\u001b[A\u001b[K\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b(B\u001b[m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[14;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":158,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[7;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":21,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;39H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":3486,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CUse /skills to list available skills\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"delayMs":199,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":40,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":40,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":40,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":40,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"}
|
||||
{"keyAt":true,"data":"\r"}
|
||||
{"delayMs":281,"data":"\u001b[16;30r\u001b[16;1H\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[18;1H"}
|
||||
{"delayMs":0,"data":"\u001b[1m\u001b[2m› \u001b(B\u001b[mhello\r\n"}
|
||||
{"delayMs":0,"data":"\u001b[22;3H\u001b[2mUse /skills to list available skills\u001b(B\u001b[m\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||
{"delayMs":12,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||
{"delayMs":6,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||
{"delayMs":6,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||
{"delayMs":118,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":1,"data":"\r\n•\u001b[C\u001b[2mWorking\u001b[C(0s • esc to interrupt)\u001b[24;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[26;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[24;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[3AW\u001b[30C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[C\u001b(B\u001b[m\u001b[1mW\u001b(B\u001b[mo\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;4H\u001b[1mo\u001b(B\u001b[mr\u001b[28C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;5H\u001b[1mr\u001b(B\u001b[mk\u001b[27C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;6H\u001b[1mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;7H\u001b[1mi\u001b(B\u001b[mn\u001b[25C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":34,"data":"\u001b[3AW\u001b[4C\u001b[1mn\u001b(B\u001b[mg\u001b[24C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;12H\u001b[2m1\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||
{"delayMs":19,"data":"\u001b[21;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":1,"data":"\r\n\u001b[2m◦\u001b[CReconne\u001b(B\u001b[mc\u001b[1mting.\u001b(B\u001b[m.\u001b[2m. 2/5\u001b[C(1s • esc to interrupt)\r\n └ Unexpected status 401 Unauthorized: {\r\n \"error\": {\r\n \"message\": \"Incorre, url: wss://api.openai.com/v1/responses, cf-ray: a2831cf59baa039d-ZRH,…\u001b[27;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[29;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[27;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":33,"data":"\u001b[21;10H\u001b[2mc\u001b(B\u001b[mt\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;11H\u001b[2mt\u001b(B\u001b[mi\u001b[4C\u001b[1m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;12H\u001b[2mi\u001b(B\u001b[mn\u001b[4C\u001b[1m \u001b(B\u001b[m2\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;13H\u001b[2mn\u001b(B\u001b[mg\u001b[4C\u001b[1m2\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;14H\u001b[2mg\u001b(B\u001b[m.\u001b[4C\u001b[1m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":36,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[?25l\u001b[?12l\u001b[?25h\u001b[27;3H"}
|
||||
{"delayMs":31,"data":"\u001b[21;15H\u001b[2m.\u001b(B\u001b[m.\u001b[4C\u001b[1m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;16H\u001b[2m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;17H\u001b[2m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":35,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;18H\u001b[2m \u001b(B\u001b[m2\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;19H\u001b[2m2\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;20H\u001b[2m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;21H\u001b[2m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[27;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":21,"data":"\u001b[21;19H\u001b[2m3\u001b(B\u001b[m\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;85H\u001b[2maca388822\u001b(B\u001b[m\u001b[6C\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;24H\u001b[2m2\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[6AR\u001b[42C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[6A\u001b[1mR\u001b(B\u001b[me\u001b[41C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":33,"data":"\u001b[21;4H\u001b[1me\u001b(B\u001b[mc\u001b[40C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;5H\u001b[1mc\u001b(B\u001b[mo\u001b[39C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;6H\u001b[1mo\u001b(B\u001b[mn\u001b[38C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;7H\u001b[1mn\u001b(B\u001b[mn\u001b[37C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[6AR\u001b[4C\u001b[1mn\u001b(B\u001b[me\u001b[36C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[6A\u001b[2mR\u001b(B\u001b[me\u001b[4C\u001b[1me\u001b(B\u001b[mc\u001b[35C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;4H\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1mc\u001b(B\u001b[mt\u001b[34C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;5H\u001b[2mc\u001b(B\u001b[mo\u001b[4C\u001b[1mt\u001b(B\u001b[mi\u001b[33C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;6H\u001b[2mo\u001b(B\u001b[mn\u001b[4C\u001b[1mi\u001b(B\u001b[mn\u001b[32C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;7H\u001b[2mn\u001b(B\u001b[mn\u001b[4C\u001b[1mn\u001b(B\u001b[mg\u001b[31C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[6Cn\u001b(B\u001b[me\u001b[4C\u001b[1mg\u001b(B\u001b[m.\u001b[8C\u001b[2m3\u001b[27;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;9H\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[29C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;10H\u001b[2mc\u001b(B\u001b[mt\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":25,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;11H\u001b[2mt\u001b(B\u001b[mi\u001b[4C\u001b[1m.\u001b(B\u001b[m \u001b[2m4\u001b(B\u001b[m\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;27H\u001b[2m, url: ws\u001b[C:/\u001b[Capi.openai.com/v1/responses, cf-ray: a2831d0298dca625-ZRH,\u001b(B\u001b[m\u001b[C\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[24;98H\u001b[2m…\u001b[27;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;12H\u001b[2mi\u001b(B\u001b[mn\u001b[4C\u001b[1m \u001b(B\u001b[m4\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;13H\u001b[2mn\u001b(B\u001b[mg\u001b[4C\u001b[1m4\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;14H\u001b[2mg\u001b(B\u001b[m.\u001b[4C\u001b[1m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;15H\u001b[2m.\u001b(B\u001b[m.\u001b[4C\u001b[1m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;16H\u001b[2m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;17H\u001b[2m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;18H\u001b[2m \u001b(B\u001b[m4\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;19H\u001b[2m4\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;20H\u001b[2m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;21H\u001b[2m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":1,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;24H\u001b[2m4\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[27;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[6AR\u001b[42C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[6A\u001b[1mR\u001b(B\u001b[me\u001b[41C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;4H\u001b[1me\u001b(B\u001b[mc\u001b[40C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;5H\u001b[1mc\u001b(B\u001b[mo\u001b[39C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;6H\u001b[1mo\u001b(B\u001b[mn\u001b[38C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;7H\u001b[1mn\u001b(B\u001b[mn\u001b[37C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[6AR\u001b[4C\u001b[1mn\u001b(B\u001b[me\u001b[36C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[6A\u001b[2mR\u001b(B\u001b[me\u001b[4C\u001b[1me\u001b(B\u001b[mc\u001b[35C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[2C\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1mc\u001b(B\u001b[mt\u001b[27;3H"}
|
||||
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[21;5H\u001b[2mc\u001b(B\u001b[mo\u001b[4C\u001b[1mt\u001b(B\u001b[mi\u001b[33C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||
@@ -0,0 +1,154 @@
|
||||
{"scenario":"streaming-real","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T09:31:57.351Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||
{"delayMs":1,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":35,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b(B\u001b[m$ "}
|
||||
{"delayMs":647,"data":"exec codex\r\n"}
|
||||
{"delayMs":479,"data":"\u001b[30d\n\u001b[K\u001b[2d\u001b[J\u001b[H\u001b[K\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":2,"data":">\u001b[C\u001b[1mYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[3;3H\u001b[33mNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[4;3H/home/arkon/default/claudeman\u001b[6;3H\u001b[39mDo\u001b[Cyou\u001b[Ctrust\u001b[Cthe\u001b[Ccontents\u001b[Cof\u001b[Cthis\u001b[Cdirectory?\u001b[CWorking\u001b[Cwith\u001b[Cuntrusted\u001b[Ccontents\u001b[Ccomes\u001b[Cwith\u001b[Chigher\u001b[7;3Hrisk\u001b[Cof\u001b[Cprompt\u001b[Cinjection.\u001b[CTrusting\u001b[Cthe\u001b[Cdirectory\u001b[Callows\u001b[Cproject-local\u001b[Cconfig,\u001b[Chooks,\u001b[Cand\u001b[Cexec\u001b[8;3Hpolicies\u001b[Cto\u001b[Cload.\u001b[10;1H\u001b[36m› 1. Yes, continue\u001b[11;3H\u001b[39m2.\u001b[CNo,\u001b[Cquit\u001b[13;3H\u001b[2mPress enter to continue\u001b[?25l\u001b(B\u001b[m"}
|
||||
{"delayMs":3830,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[13;26H\u001b[?25l"}
|
||||
{"delayMs":1,"data":"\u001b[H>\u001b[1X\u001b[1m\u001b[CYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[K\r\n\u001b[K\u001b[3;2H\u001b[1K\u001b[33m\u001b[CNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[39m\u001b[K\u001b[4;2H\u001b[1K\u001b[33m\u001b[C/home/arkon/default/claudeman\u001b[39m\u001b[K\r\n\u001b[K\u001b[6;2H\u001b[1K\u001b[CDo\u001b[1X\u001b[Cyou\u001b[1X\u001b[Ctrust\u001b[1X\u001b[Cthe\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Cof\u001b[1X\u001b[Cthis\u001b[1X\u001b[Cdirectory?\u001b[1X\u001b[CWorking\u001b[1X\u001b[Cwith\u001b[1X\u001b[Cuntrusted\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Ccomes\u001b[1X\u001b[Cwith\u001b[1X\u001b[Chigher\u001b[K\u001b[7;2H\u001b[1K\u001b[Crisk\u001b[1X\u001b[Cof\u001b[1X\u001b[Cprompt\u001b[1X\u001b[Cinjection.\u001b[1X\u001b[CTrusting\u001b[1X\u001b[Cthe\u001b[1X\u001b[Cdirectory\u001b[1X\u001b[Callows\u001b[1X\u001b[Cproject-local\u001b[1X\u001b[Cconfig,\u001b[1X\u001b[Chooks,\u001b[1X\u001b[Cand\u001b[1X\u001b[Cexec\u001b[K\u001b[8;2H\u001b[1K\u001b[Cpolicies\u001b[1X\u001b[Cto\u001b[1X\u001b[Cload.\u001b[K\r\n\u001b[K\u001b[36m\r\n› 1. Yes, continue\u001b[39m\u001b[K\u001b[11;2H\u001b[1K\u001b[C2.\u001b[1X\u001b[CNo,\u001b[1X\u001b[Cquit\u001b[K\r\n\u001b[K\u001b[13;2H\u001b[1K\u001b[2m\u001b[CPress enter to continue\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[13;26H"}
|
||||
{"keyAt":true,"data":"\r"}
|
||||
{"delayMs":235,"data":"\u001b[2;1H\u001b[J\u001b[H\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001bM\u001bM\u001bM\r\n\u001b[33m⚠\u001b[39m\u001b[1;3r\u001b[3;1H\n\u001b[1;2H\u001b[33m Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\r\n\u001b[K\u001b[1;30r\u001b[3;1H"}
|
||||
{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[5;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-SFpno1\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills t\u001b(B\u001b[m\u001b[2mo list available skills\u001b[15;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[13;3H\u001b[?12l\u001b[?25h\u001b(B\u001b[m"}
|
||||
{"delayMs":10,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;39H\u001b[K\u001b[15;82H\u001b[K\u001b[13;3H"}
|
||||
{"delayMs":12,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;39H\u001b[K\u001b[15;82H\u001b[K\u001b[13;3H"}
|
||||
{"delayMs":208,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[5;1H\u001b[J\u001b[A\u001b[K\u001b[4;30r\u001b[4;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"}
|
||||
{"delayMs":0,"data":"\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2m│ model: \u001b(B\u001b[mgpt-5.6-terra\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-SFpno1\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[12;1H\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":" \u001b[1mTip:\u001b(B\u001b[m \u001b[3mNew\u001b(B\u001b[m For a limited time, Codex is included in your plan for free – let’s build together.\u001b[14;1H•\u001b[C\u001b[2mBooting MCP server: codex_apps\u001b[C(0s • esc to interrupt)\u001b[17;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[19;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[17;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":19,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":1,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":1,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":28,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":34,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[3AB\u001b[53C\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":2,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":5,"data":"\u001b[14;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[17;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[15;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":279,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"}
|
||||
{"delayMs":86,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"}
|
||||
{"delayMs":71,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"keyAt":true,"data":"p"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"keyAt":true,"data":"y"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"w"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"s"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"keyAt":true,"data":"g"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"w"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"keyAt":true,"data":"d"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":2010,"data":"reply with the single word hello\u001b[K\u001b[17;82H\u001b[K\u001b[15;35H"}
|
||||
{"keyAt":true,"data":"\r"}
|
||||
{"delayMs":382,"data":"\u001b[13;30r\u001b[13;1H\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[15;1H"}
|
||||
{"delayMs":0,"data":"\u001b[1m\u001b[2m› \u001b(B\u001b[mreply with the single word hello\r\n"}
|
||||
{"delayMs":0,"data":"\u001b[19;3H\u001b[2mUse /skills to list available skills\u001b(B\u001b[m\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||
{"delayMs":20,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||
{"delayMs":8,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||
{"delayMs":78,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\r\n•\u001b[C\u001b[2mWor\u001b(B\u001b[mk\u001b[1ming\u001b[C\u001b(B\u001b[m\u001b[2m(0s • esc to interrupt)\u001b[21;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[23;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[21;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;6H\u001b[2mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":35,"data":"\u001b[18;7H\u001b[2mi\u001b(B\u001b[mn\u001b[25C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;8H\u001b[2mn\u001b(B\u001b[mg\u001b[24C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;9H\u001b[2mg\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[18;1H\u001b[2m◦\u001b[21;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":32,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;12H\u001b[2m1\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":32,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[18;1H•\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":34,"data":"\u001b[3AW\u001b[30C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[3A\u001b[1mW\u001b(B\u001b[mo\u001b[29C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;4H\u001b[1mo\u001b(B\u001b[mr\u001b[28C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;5H\u001b[1mr\u001b(B\u001b[mk\u001b[27C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":33,"data":"\u001b[18;6H\u001b[1mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":32,"data":"\u001b[18;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[17;30r\u001b[17;1H\u001bM\u001bM\u001b[1;30r\u001b[18;1H"}
|
||||
{"delayMs":0,"data":"\u001b[2m• \u001b(B\u001b[mhello\u001b[21;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[23;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[21;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":25,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"delayMs":6,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":3,"data":"\u001b[36C\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||
{"keyAt":true,"data":"a"}
|
||||
{"delayMs":2252,"data":"a\u001b[K\u001b[23;82H\u001b[K\u001b[21;4H"}
|
||||
{"keyAt":true,"data":"b"}
|
||||
{"delayMs":121,"data":"b\u001b[K\u001b[23;82H\u001b[K\u001b[21;5H"}
|
||||
{"keyAt":true,"data":"c"}
|
||||
{"delayMs":121,"data":"c\u001b[K\u001b[23;82H\u001b[K\u001b[21;6H"}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"scenario":"trust-modal","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:20.960Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":28,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b(B\u001b[m$ "}
|
||||
{"delayMs":654,"data":"exec codex\r\n"}
|
||||
{"delayMs":486,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":168,"data":"\u001b[30d\n\u001b[K\u001b[2d\u001b[J\u001b[H\u001b[K"}
|
||||
{"delayMs":2,"data":">\u001b[C\u001b[1mYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[3;3H\u001b[33mNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[4;3H/home/arkon/default/claudeman\u001b[6;3H\u001b[39mDo\u001b[Cyou\u001b[Ctrust\u001b[Cthe\u001b[Ccontents\u001b[Cof\u001b[Cthis\u001b[Cdirectory?\u001b[CWorking\u001b[Cwith\u001b[Cuntrusted\u001b[Ccontents\u001b[Ccomes\u001b[Cwith\u001b[Chigher\u001b[7;3Hrisk\u001b[Cof\u001b[Cprompt\u001b[Cinjection.\u001b[CTrusting\u001b[Cthe\u001b[Cdirectory\u001b[Callows\u001b[Cproject-local\u001b[Cconfig,\u001b[Chooks,\u001b[Cand\u001b[Cexec\u001b[8;3Hpolicies\u001b[Cto\u001b[Cload.\u001b[10;1H\u001b[36m› 1. Yes, continue\u001b[11;3H\u001b[39m2.\u001b[CNo,\u001b[Cquit\u001b[13;3H\u001b[2mPress enter to continue\u001b[?25l\u001b(B\u001b[m"}
|
||||
{"delayMs":3659,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[13;26H\u001b[?25l"}
|
||||
{"delayMs":0,"data":"\u001b[H>\u001b[1X\u001b[1m\u001b[CYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[K\r\n\u001b[K\u001b[3;2H\u001b[1K\u001b[33m\u001b[CNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[39m\u001b[K\u001b[4;2H\u001b[1K\u001b[33m\u001b[C/home/arkon/default/claudeman\u001b[39m\u001b[K\r\n\u001b[K\u001b[6;2H\u001b[1K\u001b[CDo\u001b[1X\u001b[Cyou\u001b[1X\u001b[Ctrust\u001b[1X\u001b[Cthe\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Cof\u001b[1X\u001b[Cthis\u001b[1X\u001b[Cdirectory?\u001b[1X\u001b[CWorking\u001b[1X\u001b[Cwith\u001b[1X\u001b[Cuntrusted\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Ccomes\u001b[1X\u001b[Cwith\u001b[1X\u001b[Chigher\u001b[K\u001b[7;2H\u001b[1K\u001b[Crisk\u001b[1X\u001b[Cof\u001b[1X\u001b[Cprompt\u001b[1X\u001b[Cinjection.\u001b[1X\u001b[CTrusting\u001b[1X\u001b[Cthe\u001b[1X\u001b[Cdirectory\u001b[1X\u001b[Callows\u001b[1X\u001b[Cproject-local\u001b[1X\u001b[Cconfig,\u001b[1X\u001b[Chooks,\u001b[1X\u001b[Cand\u001b[1X\u001b[Cexec\u001b[K\u001b[8;2H\u001b[1K\u001b[Cpolicies\u001b[1X\u001b[Cto\u001b[1X\u001b[Cload.\u001b[K\r\n\u001b[K\u001b[36m\r\n› 1. Yes, continue\u001b[39m\u001b[K\u001b[11;2H\u001b[1K\u001b[C2.\u001b[1X\u001b[CNo,\u001b[1X\u001b[Cquit\u001b[K\r\n\u001b[K\u001b[13;2H\u001b[1K\u001b[2m\u001b[CPress enter to continue\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[13;26H"}
|
||||
{"keyAt":true,"data":"x"}
|
||||
{"delayMs":188,"data":"\u001b[1;79H\u001b[K\u001b[3;95H\u001b[K\u001b[4;32H\u001b[K\u001b[6;97H\u001b[K\u001b[7;96H\u001b[K\u001b[8;20H\u001b[K\u001b[10;19H\u001b[K\u001b[11;14H\u001b[K\u001b[13;26H\u001b[K\u001b[30;2H"}
|
||||
{"keyAt":true,"data":"\r"}
|
||||
{"delayMs":849,"data":"\u001b[2;1H\u001b[J\u001b[H\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001bM\u001bM\u001bM\r\n\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b(B\u001b[m\u001b[1;3r\u001b[3;1H\n\u001b[A \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\r\n\u001b[K\u001b[1;30r\u001b[3;1H"}
|
||||
{"delayMs":2,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[5;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-X4gHpE\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove docum\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2mentation in @filename\u001b[15;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[13;3H\u001b[?12l\u001b[?25h\u001b(B\u001b[m"}
|
||||
{"delayMs":7,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;37H\u001b[K\u001b[15;80H\u001b[K\u001b[13;3H"}
|
||||
{"delayMs":13,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;37H\u001b[K\u001b[15;80H\u001b[K\u001b[13;3H"}
|
||||
{"delayMs":165,"data":"\u001b[5;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[8;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[6;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":1,"data":"\u001b[34C\u001b[K\u001b[8;80H\u001b[K\u001b[6;3H"}
|
||||
{"delayMs":24,"data":"\u001b[4;30r\u001b[4;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2m│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-X4gHpE\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[12;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[17;37H\u001b[K\u001b[19;80H\u001b[K\u001b[17;3H"}
|
||||
{"delayMs":1,"data":"\u001b[34C\u001b[K\u001b[19;80H\u001b[K\u001b[17;3H"}
|
||||
@@ -0,0 +1,33 @@
|
||||
{"scenario":"type-hello","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:33.854Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||
{"delayMs":1,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":32,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b(B\u001b[m$ "}
|
||||
{"delayMs":651,"data":"exec codex\r\n"}
|
||||
{"delayMs":403,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":189,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":7,"data":"\r\n\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":4,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b[39m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[14;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":25,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":185,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"}
|
||||
{"delayMs":0,"data":"\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2m│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n"}
|
||||
{"delayMs":0,"data":" produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[20;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[18;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[34C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[34C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":3484,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CImprove documentation in @filename\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"delayMs":208,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":93,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":89,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":92,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":90,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"}
|
||||
@@ -0,0 +1,250 @@
|
||||
{"scenario":"wrap","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:52.462Z"}
|
||||
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||
{"delayMs":32,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b(B\u001b[m$ "}
|
||||
{"delayMs":650,"data":"exec codex\r\n"}
|
||||
{"delayMs":437,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":181,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||
{"delayMs":4,"data":"\r\n\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H"}
|
||||
{"delayMs":0,"data":"\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b(B\u001b[m"}
|
||||
{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mWrite tests for @filename\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[14;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":19,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||
{"delayMs":160,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"delayMs":0,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"}
|
||||
{"delayMs":0,"data":"\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n\u001b(B\u001b[m"}
|
||||
{"delayMs":0,"data":"\u001b[2m│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mWrite tests for @filename\u001b[20;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[18;3H\u001b(B\u001b[m"}
|
||||
{"delayMs":18,"data":"\u001b[25C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":1,"data":"\u001b[25C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||
{"delayMs":3478,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CWrite tests for @filename\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"delayMs":232,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"delayMs":27,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"}
|
||||
{"keyAt":true,"data":"q"}
|
||||
{"delayMs":26,"data":"q\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"}
|
||||
{"keyAt":true,"data":"u"}
|
||||
{"delayMs":17,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;9H"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"delayMs":28,"data":"i\u001b[K\u001b[20;80H\u001b[K\u001b[18;10H"}
|
||||
{"keyAt":true,"data":"c"}
|
||||
{"delayMs":27,"data":"c\u001b[K\u001b[20;80H\u001b[K\u001b[18;11H"}
|
||||
{"keyAt":true,"data":"k"}
|
||||
{"delayMs":27,"data":"k\u001b[K\u001b[20;80H\u001b[K\u001b[18;12H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"b"}
|
||||
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;13H"}
|
||||
{"delayMs":16,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;14H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"delayMs":30,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;15H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;16H"}
|
||||
{"keyAt":true,"data":"w"}
|
||||
{"delayMs":27,"data":"w\u001b[K\u001b[20;80H\u001b[K\u001b[18;17H"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":27,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;18H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"f"}
|
||||
{"delayMs":45,"data":"\u001b[Cf\u001b[K\u001b[20;80H\u001b[K\u001b[18;20H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;21H"}
|
||||
{"keyAt":true,"data":"x"}
|
||||
{"delayMs":26,"data":"x\u001b[K\u001b[20;80H\u001b[K\u001b[18;22H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;23H"}
|
||||
{"keyAt":true,"data":"j"}
|
||||
{"keyAt":true,"data":"u"}
|
||||
{"delayMs":27,"data":"j\u001b[K\u001b[20;80H\u001b[K\u001b[18;24H"}
|
||||
{"keyAt":true,"data":"m"}
|
||||
{"delayMs":46,"data":"um\u001b[K\u001b[20;80H\u001b[K\u001b[18;26H"}
|
||||
{"keyAt":true,"data":"p"}
|
||||
{"delayMs":26,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;27H"}
|
||||
{"keyAt":true,"data":"s"}
|
||||
{"delayMs":28,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;28H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;29H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":16,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;30H"}
|
||||
{"keyAt":true,"data":"v"}
|
||||
{"delayMs":31,"data":"v\u001b[K\u001b[20;80H\u001b[K\u001b[18;31H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":26,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;32H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"delayMs":28,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;33H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;34H"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"delayMs":45,"data":"th\u001b[K\u001b[20;80H\u001b[K\u001b[18;36H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;37H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;38H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":27,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;39H"}
|
||||
{"keyAt":true,"data":"a"}
|
||||
{"keyAt":true,"data":"z"}
|
||||
{"delayMs":28,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;40H"}
|
||||
{"keyAt":true,"data":"y"}
|
||||
{"delayMs":26,"data":"z\u001b[K\u001b[20;80H\u001b[K\u001b[18;41H"}
|
||||
{"delayMs":17,"data":"y\u001b[K\u001b[20;80H\u001b[K\u001b[18;42H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":29,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;43H"}
|
||||
{"keyAt":true,"data":"d"}
|
||||
{"delayMs":27,"data":"d\u001b[K\u001b[20;80H\u001b[K\u001b[18;44H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;45H"}
|
||||
{"keyAt":true,"data":"g"}
|
||||
{"delayMs":27,"data":"g\u001b[K\u001b[20;80H\u001b[K\u001b[18;46H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"a"}
|
||||
{"delayMs":45,"data":"\u001b[Ca\u001b[K\u001b[20;80H\u001b[K\u001b[18;48H"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;49H"}
|
||||
{"keyAt":true,"data":"d"}
|
||||
{"delayMs":28,"data":"d\u001b[K\u001b[20;80H\u001b[K\u001b[18;50H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;51H"}
|
||||
{"keyAt":true,"data":"k"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":27,"data":"k\u001b[20;80H\u001b[K\u001b[18;52H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":26,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;53H"}
|
||||
{"delayMs":17,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;54H"}
|
||||
{"keyAt":true,"data":"p"}
|
||||
{"delayMs":28,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;55H"}
|
||||
{"keyAt":true,"data":"s"}
|
||||
{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;56H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;57H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"keyAt":true,"data":"u"}
|
||||
{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;58H"}
|
||||
{"delayMs":17,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;59H"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":29,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;60H"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;61H"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"delayMs":28,"data":"i\u001b[K\u001b[20;80H\u001b[K\u001b[18;62H"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;63H"}
|
||||
{"keyAt":true,"data":"g"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":45,"data":"g\u001b[K\u001b[20;80H\u001b[K\u001b[18;65H"}
|
||||
{"keyAt":true,"data":"u"}
|
||||
{"delayMs":28,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;66H"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":27,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;67H"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"delayMs":27,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;68H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":45,"data":"il\u001b[K\u001b[20;80H\u001b[K\u001b[18;70H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":28,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;71H"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"delayMs":26,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;72H"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":28,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;73H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":45,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;75H"}
|
||||
{"keyAt":true,"data":"c"}
|
||||
{"delayMs":27,"data":"c\u001b[K\u001b[20;80H\u001b[K\u001b[18;76H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":28,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;77H"}
|
||||
{"keyAt":true,"data":"m"}
|
||||
{"keyAt":true,"data":"p"}
|
||||
{"delayMs":27,"data":"m\u001b[K\u001b[20;80H\u001b[K\u001b[18;78H"}
|
||||
{"delayMs":16,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;79H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":30,"data":"o\u001b[K\u001b[2B\u001b[K\u001b[2A"}
|
||||
{"keyAt":true,"data":"s"}
|
||||
{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;81H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;82H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;83H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":16,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;84H"}
|
||||
{"keyAt":true,"data":"b"}
|
||||
{"delayMs":30,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;85H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;86H"}
|
||||
{"keyAt":true,"data":"x"}
|
||||
{"delayMs":27,"data":"x\u001b[K\u001b[20;80H\u001b[K\u001b[18;87H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;88H"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"delayMs":17,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;89H"}
|
||||
{"keyAt":true,"data":"a"}
|
||||
{"delayMs":28,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;90H"}
|
||||
{"keyAt":true,"data":"s"}
|
||||
{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;91H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":28,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;92H"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"delayMs":26,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;93H"}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;94H"}
|
||||
{"delayMs":16,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;95H"}
|
||||
{"keyAt":true,"data":"w"}
|
||||
{"delayMs":30,"data":"w\u001b[K\u001b[20;80H\u001b[K\u001b[18;96H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;97H"}
|
||||
{"keyAt":true,"data":"a"}
|
||||
{"delayMs":26,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;98H"}
|
||||
{"keyAt":true,"data":"p"}
|
||||
{"delayMs":27,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;99H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[mthe\u001b[Cquick\u001b[Cbrown\u001b[Cfox\u001b[Cjumps\u001b[Cover\u001b[Cthe\u001b[Clazy\u001b[Cdog\u001b[Cand\u001b[Ckeeps\u001b[Crunning\u001b[Cuntil\u001b[Cthe\u001b[Ccomposer\u001b[Cbox\u001b[Chas\u001b[Cto\u001b[Cwrap\u001b[21;3H\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[19;3H\u001b(B\u001b[m"}
|
||||
{"keyAt":true,"data":"h"}
|
||||
{"delayMs":42,"data":"\u001b[18;99H\u001b[K\u001b[19;3Hth\u001b[21;80H\u001b[K\u001b[19;5H"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"delayMs":29,"data":"\u001b[18;99H\u001b[K\u001b[19;5Hi\u001b[K\u001b[21;80H\u001b[K\u001b[19;6H"}
|
||||
{"keyAt":true,"data":"s"}
|
||||
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;6Hs\u001b[K\u001b[21;80H\u001b[K\u001b[19;7H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;7H\u001b[K\u001b[21;80H\u001b[K\u001b[19;8H"}
|
||||
{"keyAt":true,"data":"l"}
|
||||
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;8Hl\u001b[K\u001b[21;80H\u001b[K\u001b[19;9H"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"keyAt":true,"data":"n"}
|
||||
{"delayMs":45,"data":"\u001b[18;99H\u001b[K\u001b[19;9Hin\u001b[K\u001b[21;80H\u001b[K\u001b[19;11H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;11He\u001b[K\u001b[21;80H\u001b[K\u001b[19;12H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;12H\u001b[K\u001b[21;80H\u001b[K\u001b[19;13H"}
|
||||
{"keyAt":true,"data":"t"}
|
||||
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;13Ht\u001b[K\u001b[21;80H\u001b[K\u001b[19;14H"}
|
||||
{"keyAt":true,"data":"w"}
|
||||
{"keyAt":true,"data":"i"}
|
||||
{"delayMs":45,"data":"\u001b[18;99H\u001b[K\u001b[19;14Hwi\u001b[K\u001b[21;80H\u001b[K\u001b[19;16H"}
|
||||
{"keyAt":true,"data":"c"}
|
||||
{"delayMs":28,"data":"\u001b[18;99H\u001b[K\u001b[19;16Hc\u001b[K\u001b[21;80H\u001b[K\u001b[19;17H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;17He\u001b[K\u001b[21;80H\u001b[K\u001b[19;18H"}
|
||||
{"keyAt":true,"data":" "}
|
||||
{"keyAt":true,"data":"o"}
|
||||
{"delayMs":28,"data":"\u001b[18;99H\u001b[K\u001b[19;18H\u001b[K\u001b[21;80H\u001b[K\u001b[19;19H"}
|
||||
{"keyAt":true,"data":"v"}
|
||||
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;19Ho\u001b[K\u001b[21;80H\u001b[K\u001b[19;20H"}
|
||||
{"keyAt":true,"data":"e"}
|
||||
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;20Hv\u001b[K\u001b[21;80H\u001b[K\u001b[19;21H"}
|
||||
{"delayMs":17,"data":"\u001b[18;99H\u001b[K\u001b[19;21He\u001b[K\u001b[21;80H\u001b[K\u001b[19;22H"}
|
||||
{"keyAt":true,"data":"r"}
|
||||
{"delayMs":29,"data":"\u001b[18;99H\u001b[K\u001b[19;22Hr\u001b[K\u001b[21;80H\u001b[K\u001b[19;23H"}
|
||||
@@ -3,129 +3,204 @@
|
||||
*
|
||||
* Creates a minimal Terminal-like object that satisfies the addon's
|
||||
* requirements without needing a real xterm.js instance or DOM renderer.
|
||||
*
|
||||
* PredictiveEchoAddon additions (all ADDITIVE, existing tests unchanged):
|
||||
* mutable cursor via setCursor(), wide-char-aware getCell() on mock lines,
|
||||
* onWriteParsed/onResize emitters with fire* triggers, and opt-outs for
|
||||
* getCell support and the emitters (getCellSupport / emitters options).
|
||||
*/
|
||||
import { charCellWidth } from '../src/overlay-renderer.js';
|
||||
|
||||
interface MockLine {
|
||||
translateToString(_trimRight?: boolean): string;
|
||||
translateToString(_trimRight?: boolean): string;
|
||||
getCell?(x: number): { getChars(): string; getWidth(): number } | undefined;
|
||||
}
|
||||
|
||||
interface MockBufferOptions {
|
||||
lines: string[];
|
||||
viewportY?: number;
|
||||
baseY?: number;
|
||||
cursorX?: number;
|
||||
cursorY?: number;
|
||||
lines: string[];
|
||||
viewportY?: number;
|
||||
baseY?: number;
|
||||
cursorX?: number;
|
||||
cursorY?: number;
|
||||
}
|
||||
|
||||
interface MockTerminalOptions {
|
||||
buffer?: MockBufferOptions;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
cellWidth?: number;
|
||||
cellHeight?: number;
|
||||
/** Device-pixel char top offset (for charTop calculation). Default: 0 */
|
||||
deviceCharTop?: number;
|
||||
/** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */
|
||||
deviceCharHeight?: number;
|
||||
buffer?: MockBufferOptions;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
cellWidth?: number;
|
||||
cellHeight?: number;
|
||||
/** Device-pixel char top offset (for charTop calculation). Default: 0 */
|
||||
deviceCharTop?: number;
|
||||
/** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */
|
||||
deviceCharHeight?: number;
|
||||
/** Provide getCell() on mock lines (PredictiveEchoAddon). Default: true */
|
||||
getCellSupport?: boolean;
|
||||
/** Provide onWriteParsed/onResize emitters (PredictiveEchoAddon). Default: true */
|
||||
emitters?: boolean;
|
||||
}
|
||||
|
||||
/** Column-indexed cell access over a plain string, wide-char aware. */
|
||||
function cellAt(text: string, col: number): { getChars(): string; getWidth(): number } {
|
||||
let c = 0;
|
||||
for (const ch of text) {
|
||||
const w = charCellWidth(null, ch);
|
||||
if (col === c) return { getChars: () => ch, getWidth: () => w };
|
||||
if (w === 2 && col === c + 1) return { getChars: () => '', getWidth: () => 0 };
|
||||
c += w;
|
||||
}
|
||||
return { getChars: () => '', getWidth: () => 1 };
|
||||
}
|
||||
|
||||
export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
const bufOpts = opts.buffer ?? { lines: ['$ '] };
|
||||
const lines = bufOpts.lines;
|
||||
const viewportY = bufOpts.viewportY ?? 0;
|
||||
const baseY = bufOpts.baseY ?? viewportY;
|
||||
const cols = opts.cols ?? 80;
|
||||
const rows = opts.rows ?? Math.max(lines.length, 24);
|
||||
const cellW = opts.cellWidth ?? 8.4;
|
||||
const cellH = opts.cellHeight ?? 17;
|
||||
const bufOpts = opts.buffer ?? { lines: ['$ '] };
|
||||
const viewportY = bufOpts.viewportY ?? 0;
|
||||
const baseY = bufOpts.baseY ?? viewportY;
|
||||
const cols = opts.cols ?? 80;
|
||||
const rows = opts.rows ?? Math.max(bufOpts.lines.length, 24);
|
||||
const cellW = opts.cellWidth ?? 8.4;
|
||||
const cellH = opts.cellHeight ?? 17;
|
||||
const getCellSupport = opts.getCellSupport ?? true;
|
||||
const emitters = opts.emitters ?? true;
|
||||
|
||||
const mockLines: MockLine[] = lines.map((text) => ({
|
||||
translateToString: () => text,
|
||||
}));
|
||||
|
||||
// Create minimal DOM structure
|
||||
const element = document.createElement('div');
|
||||
element.className = 'terminal xterm';
|
||||
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'xterm-viewport';
|
||||
|
||||
const screen = document.createElement('div');
|
||||
screen.className = 'xterm-screen';
|
||||
screen.style.position = 'relative';
|
||||
|
||||
const xtermRows = document.createElement('div');
|
||||
xtermRows.className = 'xterm-rows';
|
||||
|
||||
element.appendChild(viewport);
|
||||
element.appendChild(screen);
|
||||
screen.appendChild(xtermRows);
|
||||
|
||||
// Append to document so getComputedStyle works
|
||||
document.body.appendChild(element);
|
||||
|
||||
const terminal = {
|
||||
element,
|
||||
cols,
|
||||
rows,
|
||||
options: {
|
||||
fontFamily: opts.fontFamily ?? 'monospace',
|
||||
fontSize: opts.fontSize ?? 14,
|
||||
fontWeight: opts.fontWeight ?? 'normal',
|
||||
theme: opts.theme ?? {},
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY,
|
||||
baseY,
|
||||
cursorX: bufOpts.cursorX ?? 0,
|
||||
cursorY: bufOpts.cursorY ?? 0,
|
||||
getLine: (absRow: number): MockLine | undefined => {
|
||||
return mockLines[absRow - viewportY];
|
||||
},
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: {
|
||||
cell: { width: cellW, height: cellH },
|
||||
},
|
||||
device: {
|
||||
char: {
|
||||
top: opts.deviceCharTop ?? 0,
|
||||
height: opts.deviceCharHeight ?? cellH,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Simulate loadAddon
|
||||
loadAddon(addon: { activate: (t: unknown) => void }) {
|
||||
addon.activate(this);
|
||||
},
|
||||
const makeLine = (text: string): { line: MockLine; set(t: string): void } => {
|
||||
let current = text;
|
||||
const line: MockLine = {
|
||||
translateToString: () => current,
|
||||
};
|
||||
if (getCellSupport) {
|
||||
line.getCell = (x: number) => cellAt(current, x);
|
||||
}
|
||||
return { line, set: (t: string) => (current = t) };
|
||||
};
|
||||
|
||||
return {
|
||||
terminal,
|
||||
/** Update buffer lines for subsequent calls */
|
||||
setLines(newLines: string[]) {
|
||||
mockLines.length = 0;
|
||||
for (const text of newLines) {
|
||||
mockLines.push({ translateToString: () => text });
|
||||
}
|
||||
let mockLines = bufOpts.lines.map(makeLine);
|
||||
|
||||
// Create minimal DOM structure
|
||||
const element = document.createElement('div');
|
||||
element.className = 'terminal xterm';
|
||||
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'xterm-viewport';
|
||||
|
||||
const screen = document.createElement('div');
|
||||
screen.className = 'xterm-screen';
|
||||
screen.style.position = 'relative';
|
||||
|
||||
const xtermRows = document.createElement('div');
|
||||
xtermRows.className = 'xterm-rows';
|
||||
|
||||
element.appendChild(viewport);
|
||||
element.appendChild(screen);
|
||||
screen.appendChild(xtermRows);
|
||||
|
||||
// Append to document so getComputedStyle works
|
||||
document.body.appendChild(element);
|
||||
|
||||
const writeParsedCbs = new Set<() => void>();
|
||||
const resizeCbs = new Set<(s: { cols: number; rows: number }) => void>();
|
||||
|
||||
const terminal = {
|
||||
element,
|
||||
cols,
|
||||
rows,
|
||||
options: {
|
||||
fontFamily: opts.fontFamily ?? 'monospace',
|
||||
fontSize: opts.fontSize ?? 14,
|
||||
fontWeight: opts.fontWeight ?? 'normal',
|
||||
theme: opts.theme ?? {},
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY,
|
||||
baseY,
|
||||
cursorX: bufOpts.cursorX ?? 0,
|
||||
cursorY: bufOpts.cursorY ?? 0,
|
||||
getLine: (absRow: number): MockLine | undefined => {
|
||||
return mockLines[absRow - viewportY]?.line;
|
||||
},
|
||||
/** Clean up DOM */
|
||||
cleanup() {
|
||||
element.remove();
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: {
|
||||
cell: { width: cellW, height: cellH },
|
||||
},
|
||||
device: {
|
||||
char: {
|
||||
top: opts.deviceCharTop ?? 0,
|
||||
height: opts.deviceCharHeight ?? cellH,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
...(emitters
|
||||
? {
|
||||
onWriteParsed(cb: () => void) {
|
||||
writeParsedCbs.add(cb);
|
||||
return { dispose: () => writeParsedCbs.delete(cb) };
|
||||
},
|
||||
onResize(cb: (s: { cols: number; rows: number }) => void) {
|
||||
resizeCbs.add(cb);
|
||||
return { dispose: () => resizeCbs.delete(cb) };
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Simulate loadAddon
|
||||
loadAddon(addon: { activate: (t: unknown) => void }) {
|
||||
addon.activate(this);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
terminal,
|
||||
/** Update buffer lines for subsequent calls */
|
||||
setLines(newLines: string[]) {
|
||||
mockLines = newLines.map(makeLine);
|
||||
},
|
||||
/** Update one line's text in place (PredictiveEchoAddon echo simulation) */
|
||||
setLine(index: number, text: string) {
|
||||
mockLines[index]?.set(text);
|
||||
},
|
||||
/** Move the mock cursor (PredictiveEchoAddon) */
|
||||
setCursor(x: number, y: number) {
|
||||
terminal.buffer.active.cursorX = x;
|
||||
terminal.buffer.active.cursorY = y;
|
||||
},
|
||||
/** Set scroll state (viewportY / baseY) */
|
||||
setScroll(newViewportY: number, newBaseY: number) {
|
||||
terminal.buffer.active.viewportY = newViewportY;
|
||||
terminal.buffer.active.baseY = newBaseY;
|
||||
},
|
||||
/** Fire the onWriteParsed emitter (PredictiveEchoAddon reconcile trigger) */
|
||||
fireWriteParsed() {
|
||||
for (const cb of [...writeParsedCbs]) cb();
|
||||
},
|
||||
/** Fire the onResize emitter */
|
||||
fireResize(newCols = cols, newRows = rows) {
|
||||
for (const cb of [...resizeCbs]) cb({ cols: newCols, rows: newRows });
|
||||
},
|
||||
/** Number of live onWriteParsed listeners (dispose assertions) */
|
||||
writeParsedListenerCount() {
|
||||
return writeParsedCbs.size;
|
||||
},
|
||||
/** Number of live onResize listeners (dispose assertions) */
|
||||
resizeListenerCount() {
|
||||
return resizeCbs.size;
|
||||
},
|
||||
/** Clean up DOM */
|
||||
cleanup() {
|
||||
element.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* prediction-renderer unit tests: span geometry math, seam-cover height,
|
||||
* ligature suppression, incremental add/remove keyed by seq, and geometry
|
||||
* stability under a non-1 devicePixelRatio (all dims are CSS px).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { addPredictionSpan, clearAllSpans, removePredictionSpan } from '../src/prediction-renderer.js';
|
||||
import type { CellDimensions, FontStyle } from '../src/types.js';
|
||||
|
||||
const dims: CellDimensions = { width: 9, height: 18, charTop: 1, charHeight: 16 };
|
||||
const font: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: '#e0e0e0',
|
||||
backgroundColor: '#101010',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
function makeContainer() {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function span(container: HTMLElement, map: Map<number, HTMLSpanElement>, over: Record<string, unknown> = {}) {
|
||||
addPredictionSpan(container, map, {
|
||||
seq: 1,
|
||||
row: 3,
|
||||
col: 5,
|
||||
char: 'x',
|
||||
width: 1,
|
||||
dims,
|
||||
font,
|
||||
underline: false,
|
||||
...over,
|
||||
} as never);
|
||||
return map.get((over.seq as number) ?? 1)!;
|
||||
}
|
||||
|
||||
describe('prediction-renderer', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('positions a width-1 span on the exact cell grid', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const s = span(makeContainer(), map);
|
||||
expect(s.style.left).toBe(`${5 * 9}px`);
|
||||
expect(s.style.top).toBe(`${3 * 18}px`);
|
||||
expect(s.style.width).toBe(`${9}px`);
|
||||
expect(s.textContent).toBe('x');
|
||||
});
|
||||
|
||||
it('positions a width-2 span across two cells', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const s = span(makeContainer(), map, { char: '你', width: 2 });
|
||||
expect(s.style.width).toBe(`${2 * 9}px`);
|
||||
});
|
||||
|
||||
it('covers the row seam: height is cellH+1 with line-height cellH', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const s = span(makeContainer(), map);
|
||||
expect(s.style.height).toBe(`${18 + 1}px`);
|
||||
expect(s.style.lineHeight).toBe('18px');
|
||||
});
|
||||
|
||||
it('disables ligatures and pointer events, applies font + letter-spacing', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const s = span(makeContainer(), map);
|
||||
expect(s.style.cssText).toContain("'liga' 0");
|
||||
expect(s.style.cssText).toContain("'calt' 0");
|
||||
expect(s.style.pointerEvents).toBe('none');
|
||||
expect(s.style.fontFamily).toBe('monospace');
|
||||
expect(s.style.letterSpacing).toBe('0.5px');
|
||||
expect(s.style.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('paints an opaque background over only its own cells', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const s = span(makeContainer(), map);
|
||||
expect(['#101010', 'rgb(16, 16, 16)']).toContain(s.style.backgroundColor);
|
||||
// Background is bounded by the span's own width, never a full row
|
||||
expect(s.style.width).toBe('9px');
|
||||
});
|
||||
|
||||
it('underline renders only when requested', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const container = makeContainer();
|
||||
const plain = span(container, map, { seq: 1 });
|
||||
const lined = span(container, map, { seq: 2, underline: true });
|
||||
expect(plain.style.textDecoration).toBe('');
|
||||
expect(lined.style.textDecoration).toBe('underline');
|
||||
});
|
||||
|
||||
it('adds and removes incrementally, keyed by seq', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const container = makeContainer();
|
||||
span(container, map, { seq: 1 });
|
||||
span(container, map, { seq: 2, col: 6 });
|
||||
span(container, map, { seq: 3, col: 7 });
|
||||
expect(container.children).toHaveLength(3);
|
||||
|
||||
removePredictionSpan(map, 2);
|
||||
expect(container.children).toHaveLength(2);
|
||||
expect(map.has(2)).toBe(false);
|
||||
expect(map.has(1)).toBe(true);
|
||||
expect(map.has(3)).toBe(true);
|
||||
|
||||
removePredictionSpan(map, 999); // unknown seq: no-op
|
||||
expect(container.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('clearAllSpans empties both the DOM and the map', () => {
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const container = makeContainer();
|
||||
span(container, map, { seq: 1 });
|
||||
span(container, map, { seq: 2, col: 6 });
|
||||
clearAllSpans(map);
|
||||
expect(container.children).toHaveLength(0);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('geometry is stable under devicePixelRatio 2 (dims are CSS px)', () => {
|
||||
vi.stubGlobal('devicePixelRatio', 2);
|
||||
const map = new Map<number, HTMLSpanElement>();
|
||||
const s = span(makeContainer(), map);
|
||||
expect(s.style.left).toBe(`${5 * 9}px`);
|
||||
expect(s.style.top).toBe(`${3 * 18}px`);
|
||||
expect(s.style.width).toBe('9px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* PredictiveEchoAddon unit tests: the algorithm laws (anchoring, prefix-only
|
||||
* confirmation with cursor advance, two-pass mismatch cascade with neutral
|
||||
* blanks, TTL, off-row grace, gates) and lifecycle safety.
|
||||
*
|
||||
* Timer-based cases fake `performance` explicitly: the addon clocks
|
||||
* sentAt/TTL/grace with performance.now(), which vitest does NOT fake by
|
||||
* default.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
|
||||
const TIMER_CONFIG = {
|
||||
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', 'performance'] as const,
|
||||
};
|
||||
|
||||
/** Composer-like buffer: `› ` marker + placeholder, cursor at col 2 row 0. */
|
||||
function composerMock(opts: Parameters<typeof createMockTerminal>[0] = {}) {
|
||||
return createMockTerminal({
|
||||
buffer: { lines: ['› Use /skills to list', '', ''], cursorX: 2, cursorY: 0 },
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function spansOf(mock: ReturnType<typeof createMockTerminal>): HTMLSpanElement[] {
|
||||
const screen = mock.terminal.element.querySelector('.xterm-screen')!;
|
||||
return Array.from(screen.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[];
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('PredictiveEchoAddon', () => {
|
||||
let mock: ReturnType<typeof createMockTerminal>;
|
||||
let addon: PredictiveEchoAddon;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(TIMER_CONFIG);
|
||||
mock = composerMock();
|
||||
addon = new PredictiveEchoAddon();
|
||||
addon.activate(mock.terminal as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('paints a span at the cursor cell and returns true', () => {
|
||||
expect(addon.predictChar('h')).toBe(true);
|
||||
const spans = spansOf(mock);
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(spans[0].textContent).toBe('h');
|
||||
expect(spans[0].style.left).toBe(`${2 * 8.4}px`);
|
||||
expect(spans[0].style.top).toBe('0px');
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
});
|
||||
|
||||
it('stacks predictions at anchor+cumulative width while the cursor is unmoved', () => {
|
||||
addon.predictChar('h');
|
||||
addon.predictChar('e');
|
||||
addon.predictChar('y');
|
||||
const spans = spansOf(mock);
|
||||
expect(spans.map((s) => s.style.left)).toEqual([`${2 * 8.4}px`, `${3 * 8.4}px`, `${4 * 8.4}px`]);
|
||||
expect(addon.state.anchor).toEqual({ row: 0, col: 2 });
|
||||
});
|
||||
|
||||
it('re-anchors at the new cursor once outstanding drains to zero', async () => {
|
||||
addon.predictChar('h');
|
||||
mock.setLine(0, '› h');
|
||||
mock.setCursor(3, 0);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(addon.state.anchor).toBeNull();
|
||||
|
||||
addon.predictChar('i');
|
||||
expect(addon.state.anchor).toEqual({ row: 0, col: 3 });
|
||||
expect(spansOf(mock)[0].style.left).toBe(`${3 * 8.4}px`);
|
||||
});
|
||||
|
||||
it('inline reconcile inside predictChar absorbs an echo that landed between keystrokes', () => {
|
||||
addon.predictChar('h');
|
||||
// Echo lands but no onWriteParsed fires before the next keystroke
|
||||
mock.setLine(0, '› h');
|
||||
mock.setCursor(3, 0);
|
||||
expect(addon.predictChar('i')).toBe(true);
|
||||
// 'h' confirmed inline; 'i' anchored at the advanced cursor, not stacked
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(addon.state.confirmedTotal).toBe(1);
|
||||
expect(addon.state.anchor).toEqual({ row: 0, col: 3 });
|
||||
});
|
||||
|
||||
it('confirms and removes exactly the echoed prefix (cell match + cursor advance)', async () => {
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
addon.predictChar('c');
|
||||
mock.setLine(0, '› ab');
|
||||
mock.setCursor(4, 0); // advanced past 'a' and 'b' only
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.confirmedTotal).toBe(2);
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(spansOf(mock).map((s) => s.textContent)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('partial confirmation never moves remaining spans (no jitter)', async () => {
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
const bLeft = spansOf(mock)[1].style.left;
|
||||
mock.setLine(0, '› a');
|
||||
mock.setCursor(3, 0);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(spansOf(mock)).toHaveLength(1);
|
||||
expect(spansOf(mock)[0].style.left).toBe(bLeft);
|
||||
});
|
||||
|
||||
it('does NOT confirm when the cell matches but the cursor has not advanced (in-place repaint)', async () => {
|
||||
// Predict 'U' over the placeholder whose cell already shows 'U'
|
||||
addon.predictChar('U');
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
// tmux repaints the identical row; cursor stays at the anchor
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(addon.state.confirmedTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT confirm or drop when the predicted char equals the pre-existing snapshot', async () => {
|
||||
addon.predictChar('U');
|
||||
// Several passes over the unchanged placeholder: no confirm, no cascade
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
}
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('one transient mismatch survives; a persistent foreign cell cascades (two-pass rule)', async () => {
|
||||
addon.predictChar('a');
|
||||
mock.setLine(0, '› Z'); // foreign non-blank at the predicted cell
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(1); // pass 1: survives
|
||||
|
||||
// Transient recovery resets the counter
|
||||
mock.setLine(0, '› Use /skills to list');
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
mock.setLine(0, '› Z');
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(1); // count restarted, pass 1 again
|
||||
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(0); // pass 2: cascaded
|
||||
expect(addon.state.droppedTotal).toBe(1);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('blank cells are neutral: placeholder cleared under predictions does not cascade', async () => {
|
||||
// Predict over placeholder text, then codex clears the placeholder on
|
||||
// first echo: later cells become blank, which must NOT count as
|
||||
// foreign (measured behavior; without this, fast typing over the
|
||||
// placeholder drops exactly when RTT is high).
|
||||
addon.predictChar('h');
|
||||
addon.predictChar('i');
|
||||
mock.setLine(0, '› h'); // 'h' echoed; placeholder gone; 'i' cell now blank
|
||||
mock.setCursor(3, 0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
}
|
||||
expect(addon.state.confirmedTotal).toBe(1);
|
||||
expect(addon.state.outstanding).toBe(1); // 'i' still pending, TTL-bounded
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('mismatch cascade drops the record and all later ones, earlier confirmed stay gone', async () => {
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
addon.predictChar('c');
|
||||
mock.setLine(0, '› aXX'); // 'a' echoed; foreign 'X' under 'b' and 'c'
|
||||
mock.setCursor(3, 0);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.confirmedTotal).toBe(1);
|
||||
expect(addon.state.droppedTotal).toBe(2);
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TTL expiry drops predictions and leaves no timers armed (fake timers)', () => {
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vi.advanceTimersByTime(1100);
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(addon.state.droppedTotal).toBe(2);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('TTL timer re-arms for remaining records after a partial confirm', async () => {
|
||||
addon.predictChar('a'); // t=0, deadline ~1001
|
||||
vi.advanceTimersByTime(600);
|
||||
addon.predictChar('b'); // t=600, deadline ~1601
|
||||
// Echo confirms 'a' before its TTL; 'b' remains
|
||||
mock.setLine(0, '› a');
|
||||
mock.setCursor(3, 0);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
vi.advanceTimersByTime(450); // t=1050: a's timer fired, b (age 450) survives
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(1); // re-armed for b
|
||||
vi.advanceTimersByTime(600); // t=1650: b expired
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('cursor off anchor row within grace keeps predictions; sustained off-row drops all', async () => {
|
||||
addon.predictChar('a');
|
||||
mock.setCursor(0, 5);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(1); // transient excursion tolerated
|
||||
|
||||
vi.advanceTimersByTime(200); // > cursorGraceMs (150)
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('viewportY !== baseY clears predictions (scrolled up)', async () => {
|
||||
addon.predictChar('a');
|
||||
mock.setScroll(0, 5); // user scrolled: viewport pinned above baseY
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
// And no new predictions while scrolled
|
||||
expect(addon.predictChar('b')).toBe(false);
|
||||
});
|
||||
|
||||
it('maxPending: the 33rd predictChar returns false', () => {
|
||||
for (let i = 0; i < 32; i++) {
|
||||
expect(addon.predictChar('x')).toBe(true);
|
||||
}
|
||||
expect(addon.predictChar('y')).toBe(false);
|
||||
expect(addon.state.outstanding).toBe(32);
|
||||
});
|
||||
|
||||
it('edge margin: a prediction landing within edgeMarginCells of cols returns false', () => {
|
||||
mock.setCursor(75, 0); // cols 80, margin 4: col 75 + 1 <= 76 allowed
|
||||
expect(addon.predictChar('a')).toBe(true);
|
||||
// Next lands at col 76: 77 > 76 suppressed
|
||||
expect(addon.predictChar('b')).toBe(false);
|
||||
});
|
||||
|
||||
it('predictWhen gate false suppresses painting, predictChar just returns false', () => {
|
||||
addon.setPredictWhen(() => false);
|
||||
expect(addon.predictChar('a')).toBe(false);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('setPredictWhen(null) removes the gate at runtime', () => {
|
||||
addon.setPredictWhen(() => false);
|
||||
expect(addon.predictChar('a')).toBe(false);
|
||||
addon.setPredictWhen(null);
|
||||
expect(addon.predictChar('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('multi-codepoint graphemes and control chars return false', () => {
|
||||
for (const bad of ['ab', '\x1b', '\x03', '\r', '\n', '\t', '\x7f', '👨👩👧', '']) {
|
||||
expect(addon.predictChar(bad)).toBe(false);
|
||||
}
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
// Single astral emoji IS a single codepoint: predicted (width 2)
|
||||
expect(addon.predictChar('😀')).toBe(true);
|
||||
});
|
||||
|
||||
it('CJK: 2-cell span, next prediction offsets by 2, confirm reads the leading cell', async () => {
|
||||
expect(addon.predictChar('你')).toBe(true);
|
||||
const first = spansOf(mock)[0];
|
||||
expect(first.style.width).toBe(`${2 * 8.4}px`);
|
||||
addon.predictChar('a');
|
||||
expect(spansOf(mock)[1].style.left).toBe(`${4 * 8.4}px`); // 2 + width 2
|
||||
|
||||
mock.setLine(0, '› 你');
|
||||
mock.setCursor(4, 0); // advanced past the wide char
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.confirmedTotal).toBe(1);
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
});
|
||||
|
||||
it('getCell-less terminal: ASCII fallback works, wide chars suppressed', () => {
|
||||
const bare = createMockTerminal({
|
||||
buffer: { lines: ['› ', ''], cursorX: 2, cursorY: 0 },
|
||||
getCellSupport: false,
|
||||
});
|
||||
const a = new PredictiveEchoAddon();
|
||||
a.activate(bare.terminal as never);
|
||||
expect(a.predictChar('x')).toBe(true);
|
||||
expect(a.predictChar('你')).toBe(false);
|
||||
a.dispose();
|
||||
bare.cleanup();
|
||||
});
|
||||
|
||||
it("'' and ' ' cell reads are equivalent for snapshot and confirm", async () => {
|
||||
// Snapshot beyond the line text reads '' -> normalized ' '
|
||||
mock.setLine(0, '› ');
|
||||
addon.predictChar('a'); // snapshot at col 2 is '' -> ' '
|
||||
// A repaint that writes explicit spaces must not count as foreign
|
||||
mock.setLine(0, '› ');
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(addon.state.droppedTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('predictBackspace pops newest, returns false when empty, never touches confirmed', async () => {
|
||||
expect(addon.predictBackspace()).toBe(false);
|
||||
addon.reconcile(); // the empty pop armed the anchor hold; release it
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
expect(addon.predictBackspace()).toBe(true);
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
expect(spansOf(mock).map((s) => s.textContent)).toEqual(['a']);
|
||||
|
||||
mock.setLine(0, '› a');
|
||||
mock.setCursor(3, 0);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.confirmedTotal).toBe(1);
|
||||
expect(addon.predictBackspace()).toBe(false); // confirmed text is not popped
|
||||
});
|
||||
|
||||
it('clearPredictions empties the container, resets anchor, cancels the timer', () => {
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
addon.clearPredictions();
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(addon.state.anchor).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('onWriteParsed reconcile is debounced to one pass per burst', async () => {
|
||||
addon.predictChar('a');
|
||||
mock.setLine(0, '› Z'); // foreign cell: each PASS increments mismatches
|
||||
mock.fireWriteParsed();
|
||||
mock.fireWriteParsed();
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
// Three synchronous fires coalesced into ONE pass: not dropped yet
|
||||
expect(addon.state.outstanding).toBe(1);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.state.outstanding).toBe(0); // second pass cascades
|
||||
});
|
||||
|
||||
it('onResize clears predictions (cell geometry changed)', () => {
|
||||
addon.predictChar('a');
|
||||
mock.fireResize(120, 40);
|
||||
expect(addon.state.outstanding).toBe(0);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('works without onWriteParsed via manual reconcile()', () => {
|
||||
const bare = composerMock({ emitters: false });
|
||||
const a = new PredictiveEchoAddon();
|
||||
a.activate(bare.terminal as never);
|
||||
a.predictChar('h');
|
||||
bare.setLine(0, '› h');
|
||||
bare.setCursor(3, 0);
|
||||
a.reconcile();
|
||||
expect(a.state.confirmedTotal).toBe(1);
|
||||
expect(a.state.outstanding).toBe(0);
|
||||
a.dispose();
|
||||
bare.cleanup();
|
||||
});
|
||||
|
||||
it('dispose unhooks listeners and removes the container', () => {
|
||||
expect(mock.writeParsedListenerCount()).toBe(1);
|
||||
expect(mock.resizeListenerCount()).toBe(1);
|
||||
addon.predictChar('a');
|
||||
addon.dispose();
|
||||
expect(mock.writeParsedListenerCount()).toBe(0);
|
||||
expect(mock.resizeListenerCount()).toBe(0);
|
||||
const screen = mock.terminal.element.querySelector('.xterm-screen')!;
|
||||
expect(screen.querySelector('[data-predictive-echo]')).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('every public method is safe before activate and after dispose', () => {
|
||||
const fresh = new PredictiveEchoAddon();
|
||||
expect(fresh.predictChar('a')).toBe(false);
|
||||
expect(fresh.predictBackspace()).toBe(false);
|
||||
fresh.clearPredictions();
|
||||
fresh.reconcile();
|
||||
fresh.refreshFont();
|
||||
fresh.setPredictWhen(() => true);
|
||||
expect(fresh.hasPredictions).toBe(false);
|
||||
expect(fresh.state.outstanding).toBe(0);
|
||||
|
||||
addon.dispose();
|
||||
expect(addon.predictChar('a')).toBe(false);
|
||||
expect(addon.predictBackspace()).toBe(false);
|
||||
addon.clearPredictions();
|
||||
addon.reconcile();
|
||||
addon.refreshFont();
|
||||
expect(addon.hasPredictions).toBe(false);
|
||||
});
|
||||
|
||||
it('hostile terminal stubs never propagate exceptions', () => {
|
||||
const hostile = {
|
||||
element: document.createElement('div'),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {},
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
baseY: 0,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
getLine: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const a = new PredictiveEchoAddon();
|
||||
expect(() => a.activate(hostile as never)).not.toThrow();
|
||||
expect(a.predictChar('x')).toBe(false); // getLine throws inside -> caught
|
||||
expect(() => a.reconcile()).not.toThrow();
|
||||
a.dispose();
|
||||
|
||||
// Terminal with no render dimensions: addon inert, no throws
|
||||
const dimless = composerMock();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (dimless.terminal as any)._core;
|
||||
const b = new PredictiveEchoAddon();
|
||||
b.activate(dimless.terminal as never);
|
||||
expect(b.predictChar('x')).toBe(false);
|
||||
b.dispose();
|
||||
dimless.cleanup();
|
||||
});
|
||||
|
||||
it('underlinePredictions styles spans; refreshFont re-reads the rendered color', () => {
|
||||
const themed = composerMock({ theme: { foreground: '#aabbcc', background: '#112233' } });
|
||||
// The recipe prefers the computed .xterm-rows color (what xterm really
|
||||
// renders with); give the mock rows an explicit color like a real skin.
|
||||
const rows = themed.terminal.element.querySelector('.xterm-rows') as HTMLElement;
|
||||
rows.style.color = 'rgb(170, 187, 204)';
|
||||
const a = new PredictiveEchoAddon({ underlinePredictions: true });
|
||||
a.activate(themed.terminal as never);
|
||||
a.predictChar('u');
|
||||
const span = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement;
|
||||
expect(span.style.textDecoration).toBe('underline');
|
||||
expect(span.style.color).toBe('rgb(170, 187, 204)');
|
||||
|
||||
rows.style.color = 'rgb(255, 0, 0)'; // skin change
|
||||
a.refreshFont();
|
||||
a.clearPredictions();
|
||||
a.reconcile(); // release the anchor hold armed by the clear
|
||||
a.predictChar('v');
|
||||
const span2 = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement;
|
||||
expect(span2.style.color).toBe('rgb(255, 0, 0)');
|
||||
a.dispose();
|
||||
themed.cleanup();
|
||||
});
|
||||
|
||||
it('anchor hold: backspace into echoed text suppresses prediction until a write parses', async () => {
|
||||
// \x7f went to the wire with nothing outstanding: the cursor will move
|
||||
// in a way the display has not shown, so anchoring now paints one cell
|
||||
// off (review finding: "tehh" ghosts on backspace-then-retype at RTT)
|
||||
expect(addon.predictBackspace()).toBe(false);
|
||||
expect(addon.predictChar('x')).toBe(false);
|
||||
expect(spansOf(mock)).toHaveLength(0);
|
||||
mock.fireWriteParsed(); // the display caught up
|
||||
await flushMicrotasks();
|
||||
expect(addon.predictChar('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('anchor hold: clearPredictions suppresses until a write parses (or manual reconcile)', async () => {
|
||||
addon.predictChar('a');
|
||||
addon.clearPredictions(); // consumer saw Enter/Esc/arrow/paste
|
||||
expect(addon.predictChar('b')).toBe(false);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
expect(addon.predictChar('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('anchor hold: the inline predictChar reconcile does NOT release it', () => {
|
||||
addon.clearPredictions();
|
||||
// Several keystrokes in a row before any echo: all suppressed, because
|
||||
// predictChar's inline pass must not count as the display catching up
|
||||
expect(addon.predictChar('a')).toBe(false);
|
||||
expect(addon.predictChar('b')).toBe(false);
|
||||
addon.reconcile(); // public/manual pass IS the caught-up contract
|
||||
expect(addon.predictChar('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('state getter reports outstanding/confirmedTotal/droppedTotal/anchor', async () => {
|
||||
expect(addon.state).toEqual({ outstanding: 0, confirmedTotal: 0, droppedTotal: 0, anchor: null });
|
||||
addon.predictChar('a');
|
||||
addon.predictChar('b');
|
||||
expect(addon.state.outstanding).toBe(2);
|
||||
expect(addon.state.anchor).toEqual({ row: 0, col: 2 });
|
||||
expect(addon.hasPredictions).toBe(true);
|
||||
|
||||
mock.setLine(0, '› a');
|
||||
mock.setCursor(3, 0);
|
||||
mock.fireWriteParsed();
|
||||
await flushMicrotasks();
|
||||
addon.clearPredictions();
|
||||
expect(addon.state.confirmedTotal).toBe(1);
|
||||
expect(addon.state.droppedTotal).toBe(1);
|
||||
expect(addon.hasPredictions).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Layer 3: seeded property fuzz against the REAL xterm parser. Random
|
||||
* interleavings of predictions, backspaces, clears, echo writes (correct,
|
||||
* partial, foreign), screen clears, scrolls and cursor jumps; invariants
|
||||
* checked after EVERY op:
|
||||
* 1. span count === outstanding record count, every span inside the grid
|
||||
* 2. no public method throws
|
||||
* 3. eventual convergence: after the run settles (TTL elapse + reconcile),
|
||||
* outstanding === 0 and the span container is empty
|
||||
*
|
||||
* Reproduce a failure with FUZZ_SEED=<seed> FUZZ_ITERS=<n> npx vitest run
|
||||
* test/predictive-echo-fuzz.test.ts (the failing seed+iter is in the
|
||||
* assertion message).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js';
|
||||
import { CELL_H, CELL_W, createReplayTerminal } from './replay-helpers.js';
|
||||
|
||||
const SEED = Number(process.env.FUZZ_SEED ?? 1337);
|
||||
const TOTAL_ITERS = Number(process.env.FUZZ_ITERS ?? 500);
|
||||
const BATCHES = 4;
|
||||
const TTL_MS = 5;
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const ALPHABET = [...'abcdefghij XZ!?', '你', '好', '😀'];
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function fuzzIteration(iter: number, label: string) {
|
||||
const rand = mulberry32(SEED + iter);
|
||||
const rt = createReplayTerminal(60, 12);
|
||||
const addon = new PredictiveEchoAddon({ ttlMs: TTL_MS });
|
||||
addon.activate(rt.hybrid);
|
||||
const ctx = `${label} seed=${SEED} iter=${iter}`;
|
||||
|
||||
// Park the cursor mid-screen like a composer would
|
||||
await rt.write('\x1b[6;3H');
|
||||
|
||||
const ops = 4 + Math.floor(rand() * 12);
|
||||
for (let i = 0; i < ops; i++) {
|
||||
const r = rand();
|
||||
if (r < 0.35) {
|
||||
addon.predictChar(ALPHABET[Math.floor(rand() * ALPHABET.length)]);
|
||||
} else if (r < 0.43) {
|
||||
addon.predictBackspace();
|
||||
} else if (r < 0.48) {
|
||||
addon.clearPredictions();
|
||||
} else if (r < 0.62) {
|
||||
// Correct-ish echo: write a run of random chars at the anchor and
|
||||
// leave the cursor advanced (confirms whatever happens to match)
|
||||
const a = addon.state.anchor;
|
||||
if (a) {
|
||||
const n = 1 + Math.floor(rand() * 3);
|
||||
let text = '';
|
||||
for (let k = 0; k < n; k++) text += ALPHABET[Math.floor(rand() * ALPHABET.length)];
|
||||
await rt.write(`\x1b[${a.row + 1};${a.col + 1}H${text}`);
|
||||
}
|
||||
} else if (r < 0.72) {
|
||||
// Foreign rewrite across the anchor row
|
||||
await rt.write(`\x1b[6;1H${'Q'.repeat(1 + Math.floor(rand() * 20))}`);
|
||||
} else if (r < 0.8) {
|
||||
// Scroll: newlines at the bottom push history
|
||||
await rt.write(`\x1b[12;1H${'\r\n'.repeat(1 + Math.floor(rand() * 3))}`);
|
||||
} else if (r < 0.85) {
|
||||
await rt.write('\x1b[2J\x1b[H'); // clear screen + home
|
||||
} else if (r < 0.95) {
|
||||
addon.reconcile();
|
||||
} else {
|
||||
// Cursor jump
|
||||
const row = 1 + Math.floor(rand() * 12);
|
||||
const col = 1 + Math.floor(rand() * 60);
|
||||
await rt.write(`\x1b[${row};${col}H`);
|
||||
}
|
||||
await Promise.resolve(); // flush the debounced reconcile microtask
|
||||
|
||||
// Invariant 1: span/record parity + grid bounds, after every op
|
||||
expect(rt.spanCount(), ctx).toBe(addon.state.outstanding);
|
||||
for (const s of rt.spans()) {
|
||||
const left = parseFloat(s.style.left);
|
||||
const width = parseFloat(s.style.width);
|
||||
const top = parseFloat(s.style.top);
|
||||
expect(left + width, ctx).toBeLessThanOrEqual(60 * CELL_W);
|
||||
expect(top, ctx).toBeLessThanOrEqual(11 * CELL_H);
|
||||
expect(left, ctx).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant 3: eventual convergence via echo/TTL, never via dispose
|
||||
if (addon.state.outstanding > 0) {
|
||||
await sleep(TTL_MS + 15);
|
||||
addon.reconcile();
|
||||
}
|
||||
expect(addon.state.outstanding, ctx).toBe(0);
|
||||
expect(rt.spanCount(), ctx).toBe(0);
|
||||
|
||||
addon.dispose();
|
||||
rt.cleanup();
|
||||
}
|
||||
|
||||
describe(`predictive echo fuzz (${TOTAL_ITERS} iterations, seed ${SEED})`, () => {
|
||||
const perBatch = Math.ceil(TOTAL_ITERS / BATCHES);
|
||||
for (let b = 0; b < BATCHES; b++) {
|
||||
it(`batch ${b + 1}/${BATCHES}`, async () => {
|
||||
const start = b * perBatch;
|
||||
const end = Math.min(start + perBatch, TOTAL_ITERS);
|
||||
for (let iter = start; iter < end; iter++) {
|
||||
await fuzzIteration(iter, `batch${b + 1}`);
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||
@@ -4,159 +4,155 @@ import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
|
||||
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
||||
|
||||
function term(lines: string[]) {
|
||||
return createMockTerminal({ buffer: { lines } });
|
||||
return createMockTerminal({ buffer: { lines } });
|
||||
}
|
||||
|
||||
describe('findPrompt', () => {
|
||||
describe('character strategy', () => {
|
||||
it('finds $ prompt at column 0', () => {
|
||||
const { terminal, cleanup } = term(['output line', '$ ls -la']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 1, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds > prompt', () => {
|
||||
const { terminal, cleanup } = term(['> hello']);
|
||||
const finder: PromptFinder = { type: 'character', char: '>' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds prompt with prefix (user@host)', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ command']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 11 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('scans bottom-up and returns lowest match', () => {
|
||||
const { terminal, cleanup } = term([
|
||||
'$ old prompt',
|
||||
'output',
|
||||
'$ current prompt',
|
||||
]);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 2, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns null when no prompt found', () => {
|
||||
const { terminal, cleanup } = term(['no prompt here', 'or here']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds Unicode prompt character', () => {
|
||||
const { terminal, cleanup } = term(['\u276f hello']);
|
||||
const finder: PromptFinder = { type: 'character', char: '\u276f' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
describe('character strategy', () => {
|
||||
it('finds $ prompt at column 0', () => {
|
||||
const { terminal, cleanup } = term(['output line', '$ ls -la']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 1, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('regex strategy', () => {
|
||||
it('finds regex prompt', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(15);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('matches complex PS1 patterns', () => {
|
||||
const { terminal, cleanup } = term(['(venv) user % cmd']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /%/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(12);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns null on no match', () => {
|
||||
const { terminal, cleanup } = term(['just output']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles global flag safely (strips g to avoid lastIndex)', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ cmd']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/g };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(11);
|
||||
// Call again — should return same result (no lastIndex drift)
|
||||
const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos2).toEqual(pos);
|
||||
cleanup();
|
||||
});
|
||||
it('finds > prompt', () => {
|
||||
const { terminal, cleanup } = term(['> hello']);
|
||||
const finder: PromptFinder = { type: 'character', char: '>' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('custom strategy', () => {
|
||||
it('uses custom finder function', () => {
|
||||
const { terminal, cleanup } = term(['anything']);
|
||||
const finder: PromptFinder = {
|
||||
type: 'custom',
|
||||
find: () => ({ row: 5, col: 10 }),
|
||||
};
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 5, col: 10 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles null from custom finder', () => {
|
||||
const { terminal, cleanup } = term(['anything']);
|
||||
const finder: PromptFinder = {
|
||||
type: 'custom',
|
||||
find: () => null,
|
||||
};
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
it('finds prompt with prefix (user@host)', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ command']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 11 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('scans bottom-up and returns lowest match', () => {
|
||||
const { terminal, cleanup } = term(['$ old prompt', 'output', '$ current prompt']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 2, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns null when no prompt found', () => {
|
||||
const { terminal, cleanup } = term(['no prompt here', 'or here']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds Unicode prompt character', () => {
|
||||
const { terminal, cleanup } = term(['\u276f hello']);
|
||||
const finder: PromptFinder = { type: 'character', char: '\u276f' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex strategy', () => {
|
||||
it('finds regex prompt', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(15);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('matches complex PS1 patterns', () => {
|
||||
const { terminal, cleanup } = term(['(venv) user % cmd']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /%/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(12);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns null on no match', () => {
|
||||
const { terminal, cleanup } = term(['just output']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles global flag safely (strips g to avoid lastIndex)', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ cmd']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/g };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(11);
|
||||
// Call again — should return same result (no lastIndex drift)
|
||||
const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos2).toEqual(pos);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom strategy', () => {
|
||||
it('uses custom finder function', () => {
|
||||
const { terminal, cleanup } = term(['anything']);
|
||||
const finder: PromptFinder = {
|
||||
type: 'custom',
|
||||
find: () => ({ row: 5, col: 10 }),
|
||||
};
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 5, col: 10 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles null from custom finder', () => {
|
||||
const { terminal, cleanup } = term(['anything']);
|
||||
const finder: PromptFinder = {
|
||||
type: 'custom',
|
||||
find: () => null,
|
||||
};
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTextAfterPrompt', () => {
|
||||
it('reads text after prompt with offset', () => {
|
||||
const { terminal, cleanup } = term(['$ hello world']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('hello world');
|
||||
cleanup();
|
||||
});
|
||||
it('reads text after prompt with offset', () => {
|
||||
const { terminal, cleanup } = term(['$ hello world']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('hello world');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns empty string for empty prompt line', () => {
|
||||
const { terminal, cleanup } = term(['$ ']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('');
|
||||
cleanup();
|
||||
});
|
||||
it('returns empty string for empty prompt line', () => {
|
||||
const { terminal, cleanup } = term(['$ ']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('trims trailing whitespace', () => {
|
||||
const { terminal, cleanup } = term(['$ hello ']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('hello');
|
||||
cleanup();
|
||||
});
|
||||
it('trims trailing whitespace', () => {
|
||||
const { terminal, cleanup } = term(['$ hello ']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('hello');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles offset for complex prompts', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ ls -la']);
|
||||
const prompt = { row: 0, col: 11 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('ls -la');
|
||||
cleanup();
|
||||
});
|
||||
it('handles offset for complex prompts', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ ls -la']);
|
||||
const prompt = { row: 0, col: 11 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('ls -la');
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Vite `?raw` imports used by replay-helpers.ts (fixture JSONL as strings). */
|
||||
declare module '*.jsonl?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Replay-test helpers: a structural hybrid terminal whose buffer, cursor and
|
||||
* onWriteParsed delegate to a REAL @xterm/headless Terminal (so fixtures run
|
||||
* through the real parser), while `element` is a jsdom div the addon can
|
||||
* paint spans into. Works because XtermTerminal is structurally typed.
|
||||
*
|
||||
* Also carries the test-side mirror of Codeman's classifyPredictInput() and
|
||||
* codex composer gate (the real ones live in terminal-ui.js and are pinned by
|
||||
* the repo's Layer 4 vm tests; keep the two in sync).
|
||||
*/
|
||||
import { Terminal } from '@xterm/headless';
|
||||
import type { XtermTerminal } from '../src/types.js';
|
||||
// ?raw imports keep the jsdom environment free of node: builtins
|
||||
import pasteBracketed from './fixtures/codex/paste-bracketed.jsonl?raw';
|
||||
import slashPicker from './fixtures/codex/slash-picker.jsonl?raw';
|
||||
import streamingBurst from './fixtures/codex/streaming-burst.jsonl?raw';
|
||||
import streamingReal from './fixtures/codex/streaming-real.jsonl?raw';
|
||||
import trustModal from './fixtures/codex/trust-modal.jsonl?raw';
|
||||
import typeHello from './fixtures/codex/type-hello.jsonl?raw';
|
||||
import wrap from './fixtures/codex/wrap.jsonl?raw';
|
||||
|
||||
const FIXTURES: Record<string, string> = {
|
||||
'paste-bracketed': pasteBracketed,
|
||||
'slash-picker': slashPicker,
|
||||
'streaming-burst': streamingBurst,
|
||||
'streaming-real': streamingReal,
|
||||
'trust-modal': trustModal,
|
||||
'type-hello': typeHello,
|
||||
wrap,
|
||||
};
|
||||
|
||||
export const CELL_W = 9;
|
||||
export const CELL_H = 18;
|
||||
|
||||
export interface FixtureLine {
|
||||
delayMs?: number;
|
||||
keyAt?: boolean;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface FixtureMeta {
|
||||
scenario: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
codexVersion: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export function loadFixture(name: string): { meta: FixtureMeta; lines: FixtureLine[] } {
|
||||
const content = FIXTURES[name];
|
||||
if (!content) throw new Error(`unknown fixture ${name}`);
|
||||
const raw = content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l));
|
||||
return { meta: raw[0] as FixtureMeta, lines: raw.slice(1) as FixtureLine[] };
|
||||
}
|
||||
|
||||
export interface ReplayTerminal {
|
||||
hybrid: XtermTerminal;
|
||||
term: Terminal;
|
||||
write(data: string): Promise<void>;
|
||||
cursorRowText(): string;
|
||||
rowText(viewportRow: number): string;
|
||||
spanCount(): number;
|
||||
spans(): HTMLSpanElement[];
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export function createReplayTerminal(cols: number, rows: number): ReplayTerminal {
|
||||
const term = new Terminal({ cols, rows, scrollback: 2000, allowProposedApi: true });
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'terminal xterm';
|
||||
const screen = document.createElement('div');
|
||||
screen.className = 'xterm-screen';
|
||||
const rowsEl = document.createElement('div');
|
||||
rowsEl.className = 'xterm-rows';
|
||||
element.appendChild(screen);
|
||||
screen.appendChild(rowsEl);
|
||||
document.body.appendChild(element);
|
||||
|
||||
const hybrid = {
|
||||
element,
|
||||
get cols() {
|
||||
return term.cols;
|
||||
},
|
||||
get rows() {
|
||||
return term.rows;
|
||||
},
|
||||
options: { fontFamily: 'monospace', fontSize: 14, fontWeight: 'normal', theme: {} },
|
||||
buffer: {
|
||||
active: {
|
||||
get viewportY() {
|
||||
return term.buffer.active.viewportY;
|
||||
},
|
||||
get baseY() {
|
||||
return term.buffer.active.baseY;
|
||||
},
|
||||
get cursorX() {
|
||||
return term.buffer.active.cursorX;
|
||||
},
|
||||
get cursorY() {
|
||||
return term.buffer.active.cursorY;
|
||||
},
|
||||
getLine: (y: number) => term.buffer.active.getLine(y),
|
||||
},
|
||||
},
|
||||
onWriteParsed: (cb: () => void) => term.onWriteParsed(cb),
|
||||
onResize: (cb: (s: { cols: number; rows: number }) => void) => term.onResize(cb),
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: { cell: { width: CELL_W, height: CELL_H } },
|
||||
device: { char: { top: 0, height: CELL_H } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
hybrid: hybrid as unknown as XtermTerminal,
|
||||
term,
|
||||
write: (data: string) => new Promise<void>((resolve) => term.write(data, () => resolve())),
|
||||
cursorRowText() {
|
||||
const b = term.buffer.active;
|
||||
return b.getLine(b.baseY + b.cursorY)?.translateToString(true) ?? '';
|
||||
},
|
||||
rowText(viewportRow: number) {
|
||||
const b = term.buffer.active;
|
||||
return b.getLine(b.baseY + viewportRow)?.translateToString(true) ?? '';
|
||||
},
|
||||
spanCount() {
|
||||
return element.querySelectorAll('[data-predictive-echo] span').length;
|
||||
},
|
||||
spans() {
|
||||
return Array.from(element.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[];
|
||||
},
|
||||
cleanup() {
|
||||
term.dispose();
|
||||
element.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Codeman-side mirrors (keep in sync with terminal-ui.js) ────────────
|
||||
|
||||
/** Mirror of window.CodemanTerminalInput.classifyPredictInput. */
|
||||
export function classifyPredictInput(data: string): 'char' | 'backspace' | 'clear' | 'text' {
|
||||
const cps = Array.from(data);
|
||||
if (cps.length === 1) {
|
||||
const cp = cps[0].codePointAt(0)!;
|
||||
if (cp === 0x7f) return 'backspace';
|
||||
if (cp >= 0x20) return 'char';
|
||||
return 'clear';
|
||||
}
|
||||
if (data.charCodeAt(0) === 0x1b) return 'clear';
|
||||
if (data.charCodeAt(0) >= 0x20) return 'text';
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
/** Mirror of the codex composer-row gate (CODEX_COMPOSER_ROW_RE). */
|
||||
export const CODEX_COMPOSER_ROW_RE = /^› /;
|
||||
|
||||
export function codexComposerGate(terminal: XtermTerminal): boolean {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0));
|
||||
return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ export default defineConfig([
|
||||
' this.activate(terminal);',
|
||||
' }',
|
||||
' };',
|
||||
' window.PredictiveEchoAddon=XtermZerolagInput.PredictiveEchoAddon;',
|
||||
' window.PredictiveEchoOverlay=class extends XtermZerolagInput.PredictiveEchoAddon{',
|
||||
' constructor(terminal){',
|
||||
' super({});',
|
||||
' this.activate(terminal);',
|
||||
' }',
|
||||
' };',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
|
||||
@@ -65,6 +65,22 @@ appendFileSync(
|
||||
'}\n'
|
||||
);
|
||||
|
||||
// Predictive echo (codex): separate bundle so the zerolag bundle stays byte-identical
|
||||
run('xterm-predictive-echo', 'npx esbuild packages/xterm-zerolag-input/src/predictive-echo-addon.ts --bundle --minify --format=iife --global-name=XtermPredictiveEcho --outfile=dist/web/public/vendor/xterm-predictive-echo.js');
|
||||
appendFileSync(
|
||||
join(ROOT, 'dist/web/public/vendor/xterm-predictive-echo.js'),
|
||||
'\n// Global aliases for browser usage\n' +
|
||||
'if(typeof window!=="undefined"){' +
|
||||
'window.PredictiveEchoAddon=XtermPredictiveEcho.PredictiveEchoAddon;' +
|
||||
'window.PredictiveEchoOverlay=class extends XtermPredictiveEcho.PredictiveEchoAddon{' +
|
||||
'constructor(terminal){' +
|
||||
'super({});' +
|
||||
'this.activate(terminal);' +
|
||||
'}' +
|
||||
'};' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
// 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 i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
@@ -106,6 +122,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'subagent-windows.js',
|
||||
'image-input.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
'vendor/xterm-predictive-echo.js',
|
||||
];
|
||||
const manifest = {};
|
||||
for (const file of HASHABLE) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @fileoverview Replays a codex fixture (recorded by record-codex-frames.mjs)
|
||||
* through @xterm/headless and prints the measurements the predictive-echo
|
||||
* design doc records: cursor position + composer-row text at every keystroke
|
||||
* injection point, and the final screen with cursor + baseY state.
|
||||
*
|
||||
* Usage: node scripts/dev/analyze-codex-frames.mjs <fixture.jsonl>
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Terminal } = require('@xterm/headless');
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) throw new Error('usage: analyze-codex-frames.mjs <fixture.jsonl>');
|
||||
const lines = readFileSync(file, 'utf8').trim().split('\n').map(JSON.parse);
|
||||
const meta = lines.shift();
|
||||
console.log('meta:', JSON.stringify(meta));
|
||||
|
||||
const term = new Terminal({ cols: meta.cols, rows: meta.rows, scrollback: 1000, allowProposedApi: true });
|
||||
const write = (data) => new Promise((r) => term.write(data, r));
|
||||
|
||||
const snap = () => {
|
||||
const buf = term.buffer.active;
|
||||
const row = buf.getLine(buf.baseY + buf.cursorY);
|
||||
return {
|
||||
cursorX: buf.cursorX,
|
||||
cursorY: buf.cursorY,
|
||||
baseY: buf.baseY,
|
||||
rowText: row ? row.translateToString(true) : null,
|
||||
cursorCell: row?.getCell?.(buf.cursorX)?.getChars() ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.keyAt) {
|
||||
const s = snap();
|
||||
console.log(`KEY ${JSON.stringify(line.data)} @ cursor(${s.cursorX},${s.cursorY}) baseY=${s.baseY}`);
|
||||
console.log(` row: ${JSON.stringify(s.rowText)}`);
|
||||
console.log(` cell-at-cursor: ${JSON.stringify(s.cursorCell)}`);
|
||||
} else {
|
||||
await write(line.data);
|
||||
}
|
||||
}
|
||||
|
||||
const final = snap();
|
||||
console.log('\nFINAL screen (| marks cursor row/col):');
|
||||
const buf = term.buffer.active;
|
||||
for (let y = 0; y < meta.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
let text = line ? line.translateToString(true) : '';
|
||||
if (y === final.cursorY) text = text.slice(0, final.cursorX) + '|' + text.slice(final.cursorX);
|
||||
if (text.trim()) console.log(String(y).padStart(3), JSON.stringify(text));
|
||||
}
|
||||
console.log('cursor:', JSON.stringify(final), 'viewportY:', buf.viewportY);
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @fileoverview Records real codex TUI output into JSONL fixtures for the
|
||||
* predictive-echo replay tests (packages/xterm-zerolag-input/test/codex-replay.test.ts).
|
||||
*
|
||||
* The pipeline reproduces production byte-for-byte: codex runs inside tmux
|
||||
* (status off, like tmux-manager.ts sessions) driven through a node-pty client,
|
||||
* and every chunk passes through the SAME full strip session.ts applies to
|
||||
* codex-mode output (alt-screen toggles, \x1b[3J, mouse DECSETs, with the
|
||||
* split-sequence carry). What lands in the fixture is what xterm.js receives.
|
||||
*
|
||||
* Fixture format: line 1 is a meta object {scenario, cols, rows, codexVersion,
|
||||
* recordedAt}; every following line is {delayMs, data} where delayMs is the gap
|
||||
* since the previous chunk and data is the stripped chunk. Keystroke injection
|
||||
* points are recorded as {keyAt: true, data} lines so the replay knows where
|
||||
* predictChar() calls belong.
|
||||
*
|
||||
* Usage: node scripts/dev/record-codex-frames.mjs <scenario|all> [--out <dir>]
|
||||
* Scenarios: type-hello, slash-picker, wrap, streaming-burst, paste-bracketed
|
||||
*
|
||||
* The CODEX_HOME is a throwaway temp dir with a fake auth.json; the fake key is
|
||||
* asserted absent from every recorded byte before the fixture is written.
|
||||
*/
|
||||
import pty from 'node-pty';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const FAKE_KEY = 'sk-test-123';
|
||||
const COLS = 100;
|
||||
const ROWS = 30;
|
||||
const BOOT_WAIT_MS = 4500;
|
||||
// NOT under /tmp: codex prints a "Refusing to create helper binaries under
|
||||
// temporary dir" warning that embeds the CODEX_HOME path when it lives in /tmp.
|
||||
// The repo's gitignored tmp/ avoids both the warning and the path leak.
|
||||
const SCRATCH = join(ROOT, 'tmp');
|
||||
|
||||
// Mirror of the codex-mode FULL strip in session.ts _handleTerminalOutput().
|
||||
function makeStripper() {
|
||||
let carry = '';
|
||||
return (data) => {
|
||||
data = carry + data;
|
||||
carry = '';
|
||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||
if (splitTail) {
|
||||
carry = splitTail[0];
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
}
|
||||
return data
|
||||
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
};
|
||||
}
|
||||
|
||||
// Each step: wait `waitMs` after the previous step, then write `keys` to the pty.
|
||||
const SCENARIOS = {
|
||||
'type-hello': [
|
||||
...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 90, keys: ch })),
|
||||
{ waitMs: 1500, keys: '' },
|
||||
],
|
||||
'slash-picker': [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: '/' },
|
||||
{ waitMs: 400, keys: 'm' },
|
||||
{ waitMs: 150, keys: 'o' },
|
||||
{ waitMs: 1200, keys: '\x1b' },
|
||||
{ waitMs: 500, keys: '' },
|
||||
],
|
||||
wrap: [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: '' },
|
||||
...'the quick brown fox jumps over the lazy dog and keeps running until the composer box has to wrap this line twice over'
|
||||
.split('')
|
||||
.map((ch) => ({ waitMs: 25, keys: ch })),
|
||||
{ waitMs: 1500, keys: '' },
|
||||
],
|
||||
'streaming-burst': [
|
||||
...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 40, keys: ch })),
|
||||
{ waitMs: 300, keys: '\r' },
|
||||
{ waitMs: 5000, keys: '' },
|
||||
],
|
||||
'paste-bracketed': [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: 'a' },
|
||||
{ waitMs: 90, keys: 'b' },
|
||||
{ waitMs: 400, keys: '\x1b[200~XYZpasted\x1b[201~' },
|
||||
{ waitMs: 1500, keys: '' },
|
||||
],
|
||||
// REAL-AUTH streaming (CODEX_RECORD_REAL=1 only): a genuine model response
|
||||
// streaming above the pinned composer while keystrokes land mid-stream.
|
||||
// This is the one shape the fake-key lab can never produce: real output
|
||||
// pushes lines to history (baseY grows), exercising the no-drop-on-baseY
|
||||
// rule against reality. Uses the user's real ~/.codex; the fixture is
|
||||
// secret-scanned (sk- / JWT prefixes) before it is written.
|
||||
'streaming-real': {
|
||||
realAuth: true,
|
||||
steps: [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: '\r' }, // trust dialog (untrusted workdir)
|
||||
{ waitMs: 2500, keys: '' },
|
||||
...'reply with the single word hello'.split('').map((ch) => ({ waitMs: 15, keys: ch })),
|
||||
{ waitMs: 400, keys: '\r' },
|
||||
{ waitMs: 4000, keys: 'a' }, // typed MID-STREAM
|
||||
{ waitMs: 120, keys: 'b' },
|
||||
{ waitMs: 120, keys: 'c' },
|
||||
{ waitMs: 14000, keys: '' },
|
||||
],
|
||||
},
|
||||
// First-run trust dialog: the modal surface where typed chars must NOT be
|
||||
// predicted (the predictWhen ghost eliminator). Recorded UNTRUSTED so the
|
||||
// dialog actually appears; 'x' exercises typing at a non-composer cursor.
|
||||
'trust-modal': {
|
||||
trusted: false,
|
||||
steps: [
|
||||
{ waitMs: BOOT_WAIT_MS, keys: 'x' },
|
||||
{ waitMs: 800, keys: '\r' },
|
||||
{ waitMs: 2500, keys: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function record(scenario, outDir) {
|
||||
const spec = SCENARIOS[scenario];
|
||||
if (!spec) throw new Error(`unknown scenario ${scenario}`);
|
||||
const steps = Array.isArray(spec) ? spec : spec.steps;
|
||||
const trusted = Array.isArray(spec) ? true : (spec.trusted ?? true);
|
||||
const realAuth = Array.isArray(spec) ? false : (spec.realAuth ?? false);
|
||||
if (realAuth && process.env.CODEX_RECORD_REAL !== '1') {
|
||||
console.log(`${scenario}: SKIPPED (needs CODEX_RECORD_REAL=1 and a real ~/.codex login)`);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(SCRATCH, { recursive: true });
|
||||
const lab = mkdtempSync(join(SCRATCH, 'codexrec-'));
|
||||
const workdir = mkdtempSync(join(SCRATCH, 'codexrec-work-'));
|
||||
if (!realAuth) {
|
||||
writeFileSync(join(lab, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: FAKE_KEY }));
|
||||
if (trusted) {
|
||||
// Pre-trust the workdir so boot goes straight to the composer instead of
|
||||
// the first-run trust dialog (which trust-modal records deliberately).
|
||||
writeFileSync(join(lab, 'config.toml'), `[projects."${workdir}"]\ntrust_level = "trusted"\n`);
|
||||
}
|
||||
}
|
||||
const sock = `codexrec-${process.pid}`;
|
||||
const codexVersion = execSync('codex --version', { encoding: 'utf8' }).trim();
|
||||
|
||||
const lines = [];
|
||||
const strip = makeStripper();
|
||||
let lastChunkAt = null;
|
||||
let recording = true;
|
||||
const proc = pty.spawn(
|
||||
'tmux',
|
||||
['-L', sock, '-f', '/dev/null', 'new-session', '-s', 'rec', ';', 'set', '-t', 'rec', 'status', 'off'],
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
cwd: workdir,
|
||||
env: realAuth ? { ...process.env, SHELL: '/bin/bash' } : { ...process.env, CODEX_HOME: lab, SHELL: '/bin/bash' },
|
||||
}
|
||||
);
|
||||
proc.onData((data) => {
|
||||
if (!recording) return; // teardown frames ([server exited]) stay out
|
||||
const now = performance.now();
|
||||
const stripped = strip(data);
|
||||
if (!stripped) return; // timing folds into the next chunk's delay
|
||||
lines.push({ delayMs: lastChunkAt === null ? 0 : Math.round(now - lastChunkAt), data: stripped });
|
||||
lastChunkAt = now;
|
||||
});
|
||||
|
||||
// tmux session starts with a shell; launch codex in it so the strip pipeline
|
||||
// sees the same attach-then-launch order production uses.
|
||||
await sleep(700);
|
||||
proc.write(`exec codex\r`);
|
||||
|
||||
for (const step of steps) {
|
||||
await sleep(step.waitMs);
|
||||
if (step.keys) {
|
||||
lines.push({ keyAt: true, data: step.keys });
|
||||
proc.write(step.keys);
|
||||
}
|
||||
}
|
||||
|
||||
recording = false;
|
||||
try {
|
||||
execSync(`tmux -L ${sock} kill-server`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
proc.kill();
|
||||
await sleep(200);
|
||||
|
||||
const allBytes = lines.map((l) => l.data).join('');
|
||||
if (allBytes.includes(FAKE_KEY)) throw new Error(`fixture ${scenario} leaked the fake key; NOT writing`);
|
||||
if (allBytes.includes(lab)) throw new Error(`fixture ${scenario} leaked the lab path; NOT writing`);
|
||||
if (realAuth && /sk-[A-Za-z0-9_-]{8}|eyJ[A-Za-z0-9_-]{20}/.test(allBytes))
|
||||
throw new Error(`fixture ${scenario} may contain credential material; NOT writing`);
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const meta = { scenario, cols: COLS, rows: ROWS, codexVersion, recordedAt: new Date().toISOString() };
|
||||
const out = join(outDir, `${scenario}.jsonl`);
|
||||
writeFileSync(out, [JSON.stringify(meta), ...lines.map((l) => JSON.stringify(l))].join('\n') + '\n');
|
||||
rmSync(lab, { recursive: true, force: true });
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
console.log(`${scenario}: ${lines.length} lines -> ${out}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
const arg = process.argv[2];
|
||||
const outIdx = process.argv.indexOf('--out');
|
||||
const outDir =
|
||||
outIdx !== -1 ? process.argv[outIdx + 1] : join(ROOT, 'packages', 'xterm-zerolag-input', 'test', 'fixtures', 'codex');
|
||||
const wanted = arg === 'all' || !arg ? Object.keys(SCENARIOS) : [arg];
|
||||
for (const s of wanted) {
|
||||
await record(s, outDir);
|
||||
}
|
||||
@@ -304,6 +304,35 @@ if (isGlobalInstall) {
|
||||
} catch {
|
||||
console.log(colors.yellow('⚠ Failed to bundle xterm-zerolag-input — overlay may not work in dev mode'));
|
||||
}
|
||||
|
||||
// Predictive echo (codex): SEPARATE bundle so the zerolag bundle above stays
|
||||
// byte-identical. If this file is missing or broken, codex simply falls back
|
||||
// to plain PTY echo (pre-predictive behavior); nothing else is affected.
|
||||
try {
|
||||
const predSrc = join(import.meta.dirname, '..', 'packages', 'xterm-zerolag-input', 'src', 'predictive-echo-addon.ts');
|
||||
const predOut = join(vendorDir, 'xterm-predictive-echo.js');
|
||||
execSync(
|
||||
`npx esbuild "${predSrc}" --bundle --format=iife --global-name=XtermPredictiveEcho --outfile="${predOut}"`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const { appendFileSync } = await import('fs');
|
||||
appendFileSync(
|
||||
predOut,
|
||||
'\n// Global aliases for browser usage\n' +
|
||||
'if(typeof window!=="undefined"){' +
|
||||
'window.PredictiveEchoAddon=XtermPredictiveEcho.PredictiveEchoAddon;' +
|
||||
'window.PredictiveEchoOverlay=class extends XtermPredictiveEcho.PredictiveEchoAddon{' +
|
||||
'constructor(terminal){' +
|
||||
'super({});' +
|
||||
'this.activate(terminal);' +
|
||||
'}' +
|
||||
'};' +
|
||||
'}\n'
|
||||
);
|
||||
console.log(colors.green('✓ xterm-predictive-echo bundled to vendor/'));
|
||||
} catch (e) {
|
||||
console.log(colors.yellow('⚠ predictive-echo bundle failed (codex uses plain echo): ' + e.message));
|
||||
}
|
||||
} catch (err) {
|
||||
hasWarnings = true;
|
||||
console.log(colors.yellow('⚠ Failed to copy xterm vendor files'));
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Populate `src/web/public/vendor/` with the browser bundles the mobile tests need.
|
||||
*
|
||||
* The mobile suite (test/mobile/**) drives a real browser against a WebServer
|
||||
* started from TypeScript source, so fastify-static serves
|
||||
* `join(__dirname, 'public')` = `src/web/public`, NOT `dist/web/public`, where
|
||||
* `npm run build` puts the vendor bundles. Without them every `/vendor/xterm*`
|
||||
* request 404s, so `Terminal` is never defined, `initTerminal()` never runs, and
|
||||
* every test touching `app.terminal` dies with `Cannot read properties of null`.
|
||||
*
|
||||
* That stayed invisible because config/vitest.ci.config.ts excludes
|
||||
* `test/mobile/**`, so CI never ran the suite.
|
||||
*
|
||||
* ⚠️ scripts/postinstall.js:238-303 already writes these same 7 outputs (same
|
||||
* names, same alias tail), so a plain `npm install` leaves the suite working. What
|
||||
* this script adds is FRESHNESS and independence from install time: a checkout
|
||||
* installed with `--ignore-scripts`, or one borrowing another tree's
|
||||
* `node_modules`, never ran postinstall, and an edit to the zerolag package after
|
||||
* install leaves the bundle stale. It runs as `pretest:mobile`.
|
||||
*
|
||||
* Mirrors the vendor steps in scripts/build.mjs, targeting the source tree. Same
|
||||
* inputs and output names, so the page markup needs no test-only branch. That
|
||||
* makes THREE hand-synced copies of this asset table (here, build.mjs:45-51,
|
||||
* postinstall.js:255-303); keep them in step or a missing entry becomes a 404 that
|
||||
* silently disables the terminal.
|
||||
* `src/web/public/vendor/` is gitignored, so these stay build artifacts.
|
||||
*
|
||||
* Idempotent: skips outputs that are complete and newer than every input they
|
||||
* derive from.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OUT = join(ROOT, 'src', 'web', 'public', 'vendor');
|
||||
const NM = join(ROOT, 'node_modules');
|
||||
|
||||
/**
|
||||
* Every `vendor/` asset index.html requests, minus the two already committed
|
||||
* (dompurify, marked). Kept in sync with scripts/build.mjs steps 3-4 — a missing
|
||||
* entry here is a 404 that silently disables the terminal in tests.
|
||||
*
|
||||
* mode: 'copy' | 'minify' | 'bundle'
|
||||
*/
|
||||
const ASSETS = [
|
||||
{ src: join(NM, '@xterm/xterm/css/xterm.css'), out: 'xterm.css', mode: 'copy' },
|
||||
{ src: join(NM, '@xterm/xterm/lib/xterm.js'), out: 'xterm.min.js', mode: 'minify' },
|
||||
{ src: join(NM, '@xterm/addon-fit/lib/addon-fit.js'), out: 'xterm-addon-fit.min.js', mode: 'minify' },
|
||||
{
|
||||
src: join(NM, '@xterm/addon-serialize/lib/addon-serialize.js'),
|
||||
out: 'xterm-addon-serialize.min.js',
|
||||
mode: 'minify',
|
||||
},
|
||||
{
|
||||
src: join(NM, '@xterm/addon-unicode11/lib/addon-unicode11.js'),
|
||||
out: 'xterm-addon-unicode11.min.js',
|
||||
mode: 'minify',
|
||||
},
|
||||
{ src: join(NM, '@xterm/addon-webgl/lib/addon-webgl.js'), out: 'xterm-addon-webgl.min.js', mode: 'copy' },
|
||||
{
|
||||
src: join(ROOT, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts'),
|
||||
out: 'xterm-zerolag-input.js',
|
||||
mode: 'bundle',
|
||||
globalName: 'XtermZerolagInput',
|
||||
// The alias tail appended below. Its absence means the output is a partial
|
||||
// write from an older version of this script, whatever its mtime says.
|
||||
mustContain: 'window.LocalEchoOverlay',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Every input an asset is derived from. For the bundle that is the whole package
|
||||
* source dir, not just the entry: esbuild pulls in the entry's siblings, so
|
||||
* comparing against the entry alone reports "up to date" after an edit to
|
||||
* overlay-renderer.ts and the suite then tests a stale overlay. Editing those
|
||||
* siblings is exactly the single-source workflow CLAUDE.md mandates.
|
||||
*/
|
||||
function sourcesOf(asset) {
|
||||
if (asset.mode !== 'bundle') return [asset.src];
|
||||
const dir = dirname(asset.src);
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.ts'))
|
||||
.map((f) => join(dir, f));
|
||||
} catch {
|
||||
return [asset.src];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A truncated output is the other half of the poisoned-cache problem, and the one
|
||||
* `mustContain` cannot cover on its own: an interrupted write leaves a SHORT file
|
||||
* carrying a current mtime, which the cache then trusts forever. This script
|
||||
* publishes atomically so it can no longer create one, but postinstall.js:266-303
|
||||
* still writes this same directory in place, so a Ctrl+C during `npm install`
|
||||
* produces exactly that, and a 200-byte xterm.min.js means `Terminal` is undefined
|
||||
* and every test dies on a null `app.terminal`.
|
||||
*
|
||||
* A copy must match its source byte for byte. A derived output is held to a floor
|
||||
* far below the real ratios (0.97-1.00 for the minified assets, 0.51 for the
|
||||
* bundle), so a dependency upgrade cannot trip it while a truncation misses by
|
||||
* orders of magnitude.
|
||||
*/
|
||||
const MIN_DERIVED_RATIO = 0.1;
|
||||
|
||||
function isCompleteSize(asset, dest) {
|
||||
const srcBytes = statSync(asset.src).size;
|
||||
const destBytes = statSync(dest).size;
|
||||
if (asset.mode === 'copy') return destBytes === srcBytes;
|
||||
return destBytes >= srcBytes * MIN_DERIVED_RATIO;
|
||||
}
|
||||
|
||||
function isFresh(asset, dest) {
|
||||
if (!existsSync(dest)) return false;
|
||||
try {
|
||||
// Size and content checks before the mtime check, because mtime cannot see a
|
||||
// WRONG file.
|
||||
if (!isCompleteSize(asset, dest)) return false;
|
||||
// The atomic rename below stops this script from ever publishing a half-written
|
||||
// bundle, but it cannot repair one already on disk: anyone who ran an earlier
|
||||
// version that appended the aliases in place has a complete-looking file with a
|
||||
// current mtime and no alias tail, and a pure mtime cache calls that "up to
|
||||
// date" forever while the suite dies on `LocalEchoOverlay is not defined`.
|
||||
if (asset.mustContain && !readFileSync(dest, 'utf-8').includes(asset.mustContain)) return false;
|
||||
const destMs = statSync(dest).mtimeMs;
|
||||
return sourcesOf(asset).every((src) => destMs >= statSync(src).mtimeMs);
|
||||
} catch {
|
||||
// an unreadable or vanished input: rebuild rather than trust the cache
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// A run killed between its build and its rename leaks a temp, and the per-pid
|
||||
// names above mean nothing reclaims it later. Sweep the ones whose owning process
|
||||
// is gone, and ONLY those: deleting a live run's temp is the collision the per-pid
|
||||
// name exists to prevent. `kill(pid, 0)` throws ESRCH only when no such process
|
||||
// exists (EPERM means it does, owned by someone else, so leave it alone).
|
||||
for (const name of readdirSync(OUT)) {
|
||||
const owner = /\.(\d+)\.tmp$/.exec(name);
|
||||
const pid = owner ? Number(owner[1]) : 0;
|
||||
// 0 is never a real owner: to kill(2) it means "this process group".
|
||||
if (!pid) continue;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (err) {
|
||||
// ESRCH alone means the owner is gone. Anything else (EPERM = alive under
|
||||
// another user, a pid too large to be valid) leaves the file where it is.
|
||||
if (err.code !== 'ESRCH') continue;
|
||||
try {
|
||||
rmSync(join(OUT, name), { force: true });
|
||||
} catch {
|
||||
// Reclaiming litter must never fail the run: a leftover temp is inert
|
||||
// (gitignored, referenced by nothing), a crashed prepare step is not.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let built = 0;
|
||||
let skipped = 0;
|
||||
for (const asset of ASSETS) {
|
||||
const dest = join(OUT, asset.out);
|
||||
if (!existsSync(asset.src)) {
|
||||
console.error(`[test-vendor] missing input: ${asset.src}\n run \`npm install\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (isFresh(asset, dest)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Build into a temp path and rename into place at the very end. The zerolag
|
||||
// bundle is finished by a SECOND step (the alias append below), so writing
|
||||
// `dest` directly leaves a window where a complete-looking file with a current
|
||||
// mtime is missing its tail: `isFresh` then reports "up to date" forever and the
|
||||
// suite dies on `LocalEchoOverlay is not defined`, which is the exact failure
|
||||
// this script exists to prevent. An interrupted esbuild or copy poisons the
|
||||
// cache the same way. rename(2) is atomic within a directory, so a reader sees
|
||||
// either the old file or the finished new one, never a half-written one.
|
||||
// The name carries our pid: the path must be private to this run. Two runs
|
||||
// sharing one temp path fight over it, and losing that fight is not just a
|
||||
// crash — a sibling's `rmSync` landing between the esbuild and the append below
|
||||
// makes appendFileSync CREATE the file, so the rename publishes a bundle-less
|
||||
// file consisting only of the alias tail. That file still contains
|
||||
// `mustContain`, so the cache would bless it forever.
|
||||
const tmp = `${dest}.${process.pid}.tmp`;
|
||||
rmSync(tmp, { force: true });
|
||||
// cwd: ROOT so `npx` resolves the repo's pinned esbuild. Without it a run from
|
||||
// another directory misses the local install and fetches an unpinned one.
|
||||
const run = (args) => execFileSync('npx', args, { stdio: 'inherit', cwd: ROOT });
|
||||
try {
|
||||
if (asset.mode === 'copy') {
|
||||
copyFileSync(asset.src, tmp);
|
||||
} else if (asset.mode === 'minify') {
|
||||
run(['esbuild', asset.src, '--minify', `--outfile=${tmp}`]);
|
||||
} else {
|
||||
run([
|
||||
'esbuild',
|
||||
asset.src,
|
||||
'--bundle',
|
||||
'--minify',
|
||||
'--format=iife',
|
||||
`--global-name=${asset.globalName}`,
|
||||
`--outfile=${tmp}`,
|
||||
]);
|
||||
}
|
||||
|
||||
// The zerolag bundle exports only `XtermZerolagInput`. app.js constructs
|
||||
// `new LocalEchoOverlay(terminal)` directly, so scripts/build.mjs appends
|
||||
// global aliases after esbuild — without them initTerminal() throws
|
||||
// `LocalEchoOverlay is not defined` at the point it builds the overlay, and
|
||||
// every later step (including the mobile touch handlers) silently never runs.
|
||||
if (asset.out === 'xterm-zerolag-input.js') {
|
||||
appendFileSync(
|
||||
tmp,
|
||||
'\n// Global aliases for browser usage\n' +
|
||||
'if(typeof window!=="undefined"){' +
|
||||
'window.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){' +
|
||||
'super({prompt:{type:"character",char:"\\u276f",offset:2}});' +
|
||||
'this.activate(terminal);' +
|
||||
'}' +
|
||||
'};' +
|
||||
'}\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Only now is the output complete, so publish it. The append and the rename
|
||||
// are inside this try as well: a failure there has to clean the temp up and
|
||||
// report like any other, not leak it behind a raw stack trace.
|
||||
renameSync(tmp, dest);
|
||||
} catch (err) {
|
||||
rmSync(tmp, { force: true });
|
||||
console.error(`[test-vendor] failed to produce ${asset.out} from ${asset.src}\n ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
built += 1;
|
||||
}
|
||||
|
||||
console.log(`[test-vendor] ${built} built, ${skipped} up to date -> src/web/public/vendor/`);
|
||||
@@ -0,0 +1,396 @@
|
||||
---
|
||||
name: codeman
|
||||
description: >-
|
||||
Drive Codeman, the session manager this agent is running inside, over its HTTP API:
|
||||
list sessions, start worker sessions, send them prompts, block until they finish
|
||||
(wait / wait-output / send-and-wait), read their output, and clean up. Use when asked
|
||||
to orchestrate or parallelize work across Codeman sessions, watch another session, or
|
||||
start and manage workers. Only usable inside a Codeman-managed session
|
||||
(CODEMAN_MUX=1); refuse to act otherwise.
|
||||
---
|
||||
|
||||
# Driving Codeman from inside a session
|
||||
|
||||
You are an agent running inside a Codeman-managed terminal session. Codeman is the
|
||||
server that spawned you; its HTTP API can start, prompt, watch, and delete other
|
||||
sessions. Every recipe below was verified live. Full endpoint tables and
|
||||
troubleshooting: [reference/endpoints.md](reference/endpoints.md). Worked multi-worker
|
||||
flows: [reference/recipes.md](reference/recipes.md).
|
||||
|
||||
## 0. Guard, and the one thing that breaks every recipe below
|
||||
|
||||
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
|
||||
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
|
||||
the next call, and `$$` is a different pid. Three consequences, all of which have
|
||||
teeth:
|
||||
|
||||
- **Re-run this entire preamble at the top of every Bash call that touches the API.**
|
||||
Running it once and assuming it stuck is the single most likely way to break a run.
|
||||
- **Never re-paste only half of it.** The delete guard below is written so that a
|
||||
missing definition deletes nothing, but that only holds if you never hand-roll a
|
||||
`DELETE` of your own.
|
||||
- **Never put `$$` in a `clientId`.** It changes per call, so the "resend the identical
|
||||
request" loop in §3 would stop being a duplicate and would **retype the prompt**,
|
||||
submitting the turn twice. Use a fixed literal (`codeman-agent-1` below).
|
||||
|
||||
Only real environment variables (`CODEMAN_*`) survive, which is why this preamble
|
||||
rebuilds everything else from them.
|
||||
|
||||
```bash
|
||||
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; }
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Codeman does NOT hand a session the server password. If one is set, the two
|
||||
# in-reach copies are the data dir's .env (the same fallback `codeman attach`
|
||||
# uses — hand-authored; nothing ever writes it) and the supervisor definition
|
||||
# that install.sh wrote the password into, which is where a stock
|
||||
# password-protected install actually keeps it. The data dir is wherever the
|
||||
# hook-secret file lives. Values may be quoted or `export`-prefixed.
|
||||
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
|
||||
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
|
||||
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
|
||||
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
|
||||
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
|
||||
fi
|
||||
if [ -z "${CODEMAN_PASSWORD:-}" ]; then # stock installs: install.sh puts it in the service definition
|
||||
UNIT="$HOME/.config/systemd/user/codeman-web.service"
|
||||
PLIST="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if [ -f "$UNIT" ]; then
|
||||
# install.sh backslash-escapes " and \ in the unit value; undo it or a password
|
||||
# containing either recovers wrong and auth fails.
|
||||
CODEMAN_PASSWORD=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$UNIT" | head -1 | sed 's/\\\(["\\]\)/\1/g')
|
||||
elif [ -f "$PLIST" ]; then
|
||||
# install.sh XML-escapes the plist value; undo it (& LAST, mirroring escape order).
|
||||
CODEMAN_PASSWORD=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$PLIST" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p' \
|
||||
| sed -e 's/</</g' -e 's/>/>/g' -e 's/&/\&/g')
|
||||
fi
|
||||
fi
|
||||
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
|
||||
CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on https (self-signed cert)
|
||||
|
||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||
# `is_self "$SID" || curl -X DELETE ...` shape failed OPEN, because an undefined
|
||||
# is_self exits 127 and the `||` branch then ran the delete completely unguarded.
|
||||
# Undefined delete_session is "command not found", which deletes nothing.
|
||||
delete_session() {
|
||||
local id="${1:-}"
|
||||
[ -n "$id" ] || { echo "refusing: empty session id"; return 1; }
|
||||
[ "${#SELF}" -ge 8 ] || { echo "refusing: \$SELF unset or too short to prove this is not me"; return 1; }
|
||||
# ids appear in full AND 8-char form (Docker exports a truncated $SELF; mux names and
|
||||
# UI surfaces carry 8-char ids), so compare by prefix in BOTH directions. Equality or
|
||||
# a one-directional check each miss a real combination, and the miss deletes you.
|
||||
case "$id" in "$SELF"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||
case "$SELF" in "$id"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||
"${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id"
|
||||
}
|
||||
|
||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$" (see §0)
|
||||
```
|
||||
|
||||
- If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
you are not part of is not yours to drive.
|
||||
- **A 401 is plain text, not the JSON envelope**, so on a password-protected server
|
||||
every `jq` in these recipes dies with `jq: parse error` instead of showing
|
||||
`UNAUTHORIZED`. If that happens, check the status with `-w '%{http_code}'`; if it
|
||||
is 401 and neither fallback above found a credential, **stop and tell the user
|
||||
you need credentials**. The hook-secret bypass covers only `/api/hook-event` and
|
||||
`/api/status-telemetry`, never session control.
|
||||
- These endpoints first ship in Codeman **1.13.0**, but do not gate on the version
|
||||
number: a dev build can serve them while reporting an older version. Probe
|
||||
instead: `GET .../wait` on a real session id answering 404 with an `.error`
|
||||
starting `Route ` means the server predates the wait endpoints (fall back to
|
||||
polling `GET .../terminal?tail=` and say so); `Session ... not found` means your
|
||||
session id is wrong, not the server.
|
||||
|
||||
## 1. Safety rules — read before any mutating call
|
||||
|
||||
You are yourself a session on this server, and the API has **no undo**.
|
||||
|
||||
- **Never act on your own session, and know that `delete_session` is the ONLY guard.**
|
||||
The server has no self-protection: a session that DELETEs its own id succeeds and
|
||||
dies silently (verified live). **Always delete through `delete_session "$SID"` from
|
||||
§0; never write a bare `curl -X DELETE` and never reintroduce the
|
||||
`is_self … || curl -X DELETE …` shape.** That older form failed open: with the
|
||||
function undefined (a half-re-pasted preamble, see §0) bash returns 127, the `||`
|
||||
branch fires, and the delete runs with no self-check at all. Wrapping the request
|
||||
inside the guard is what makes a lost preamble delete nothing instead of deleting
|
||||
you. Apply the same prefix-both-directions reasoning before any kill, respawn, or
|
||||
input call you write by hand.
|
||||
- **Mutating calls you may make unprompted** (this is an allowlist):
|
||||
`POST /api/v1/quick-start`, `POST /api/v1/sessions/:id/input`, and
|
||||
`DELETE /api/v1/sessions/:id` **only** for a session you created in this
|
||||
conversation, by exact id. Keep a list of the ids you create. Everything else
|
||||
mutating needs the user to have asked for it.
|
||||
- **Never call these** unless the user explicitly asked, naming the target:
|
||||
- `DELETE /api/cases/:name` — recursively **deletes a real directory of the user's
|
||||
code** from disk. One wrong case name destroys work that was never yours.
|
||||
- `DELETE /api/sessions` (no id) and `DELETE /api/subagents` (no id) — bulk kills.
|
||||
- respawn / ralph / orchestrator / cron mutations — respawn runs `/clear` (wipes a
|
||||
conversation), orchestrator state is a single global slot, cron jobs outlive you.
|
||||
- `PUT /api/settings`, `POST /api/system/update` — global UI settings; server restart.
|
||||
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface.
|
||||
- Sessions count against a 50-session cap and case creation is uncapped: clean up every
|
||||
session you start, and don't retry `quick-start` in a loop.
|
||||
|
||||
## 2. Rules of the road
|
||||
|
||||
- **End every input with `\r`** — literally the two characters `\r` inside the JSON
|
||||
string. Codeman types the text and sends Enter **only when the input contains a
|
||||
carriage return**; without it your command sits unsubmitted on the worker's prompt
|
||||
and everything downstream times out. `{"input":"run the tests\r",...}`. No response
|
||||
field catches this: `delivered:true` means "written to the pane", **not**
|
||||
"submitted" — a `\r`-less send still reports `delivered:true` and then every wait
|
||||
times out, which is why the loops below are bounded and check the terminal.
|
||||
- **Single-line input only.** Newlines are stripped; one line per call.
|
||||
- **Build request bodies with `jq -n` for any prompt you did not author as a
|
||||
literal.** The inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first
|
||||
double quote, backslash, or `$` in a real prompt:
|
||||
|
||||
```bash
|
||||
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' --data-binary "$BODY"
|
||||
```
|
||||
- **Exactly-once delivery**: always send a stable `clientId` and a monotonic
|
||||
per-session `seq` on `POST .../input`. A retry after a dropped connection then
|
||||
cannot double-type the prompt. Increment `seq` for each NEW input; reuse the same
|
||||
pair only to re-ask about the same delivery.
|
||||
- **Envelope**: success is `{"success":true,"data":…}`, errors are
|
||||
`{"success":false,"error","errorCode"}`. Read `.data`. Use `/api/v1/*` paths.
|
||||
- **A wait timeout is HTTP 200**, `{wait:{timedOut:true,signal:null}}` — not an error.
|
||||
Loop over short waits (60 s); proxies cut long-idle connections. Timeouts are
|
||||
**clamped** (ceiling 600 s): read back `wait.timeoutMs` for what was applied. The
|
||||
clamp covers positive integers only: `0`, a negative, a fraction or `30s` is a 400,
|
||||
so round any computed remainder and drop it entirely rather than sending zero.
|
||||
- **Never branch on `.data.status`.** It is a heuristic and is often wrong in both
|
||||
directions: measured on a live claude worker reading `idle` while it was mid-turn
|
||||
and actively producing output (`lastActivityAt` equal to the moment of the call),
|
||||
and a worker that died inside its pane also reads `idle`. Synchronize on `stop` via
|
||||
send-and-wait, or on an output marker. To judge from outside, sample
|
||||
`terminal?tail=` twice a few seconds apart: a changing buffer is the only cheap
|
||||
positive proof a worker is still working. `wait?until=exit` is the death check.
|
||||
- **`stop` and `blocked` fire for `claude` sessions only** (Claude Code hooks). On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`, requesting them explicitly is a
|
||||
400 — and lifecycle transitions there are coarse (a short shell command may emit
|
||||
**no** `idle` transition at all, verified live), so synchronize those modes with
|
||||
output markers, not signals.
|
||||
- **Your typed command echoes into the output stream**, so a marker that appears
|
||||
verbatim in the input line matches **before the command runs**. Always split the
|
||||
marker (recipe below), keep it unique per call, and use `from=buffer` so a marker
|
||||
that printed before your wait landed is still found. Matching is literal — no regex.
|
||||
- **Match single space-free tokens against TUI output.** A full-screen TUI (claude,
|
||||
codex, …) positions text with cursor movements, not literal spaces, so the stripped
|
||||
stream can read `Yes,Itrustthisfolder` and a multi-word match is unreliable there —
|
||||
whether a phrase keeps its spaces depends on how the TUI happened to draw it
|
||||
(observed live: some match, some never fire). Plain command output (shell workers,
|
||||
`echo` lines) keeps real spaces.
|
||||
|
||||
## 3. Recipes (each verified live)
|
||||
|
||||
**List sessions / find yourself** — metadata only, safe to poll:
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" "$API/api/v1/sessions" | jq '.data[] | {id, name, mode, status}'
|
||||
"${CURL[@]}" "$API/api/v1/sessions" | jq --arg s "$SELF" '.data[] | select(.id | startswith($s))'
|
||||
```
|
||||
|
||||
**Start a claude worker and wait until it is actually ready.** A new session reports
|
||||
`idle` before its CLI has spawned, and a brand-new case shows a **trust dialog**
|
||||
first, so neither "wait for idle" nor "wait for ❯" means ready (the trust dialog
|
||||
contains `❯` too — observed live). Codeman *can* auto-accept that dialog itself, but
|
||||
the accept rides a stream match that misses on some runs (both outcomes seen live),
|
||||
so wait for the composer first and handle the dialog only as the bounded fallback —
|
||||
never send a blind Enter up front (if auto-accept already fired, it lands in the
|
||||
composer). Stage 1 is short on purpose: an already-trusted case matches `shift+tab` in
|
||||
under a second, while a **virgin case can never pass stage 1** (the dialog is up, so
|
||||
the composer is not) and always pays it in full before the fallback runs — the long
|
||||
budget belongs to stage 3, after the dialog is answered.
|
||||
|
||||
⚠️ **Match `shift+tab`, never `bypass`.** The permission mode is a server-side setting
|
||||
(`claudeMode`) that is **not** exposed on `GET /api/v1/sessions/:id`, so you cannot read
|
||||
which mode a worker runs. `bypass permissions on` is only the DEFAULT mode's statusline.
|
||||
Measured against claude-cli 2.1.226, one pane per mode:
|
||||
|
||||
| how Codeman spawned it | statusline reads | `shift+tab` | `bypass` |
|
||||
|------------------------|------------------|-------------|----------|
|
||||
| `--dangerously-skip-permissions` (default) | `bypass permissions on` | yes | yes |
|
||||
| `--permission-mode auto` | `auto mode on` | yes | no |
|
||||
| `--allowedTools …` | `don't ask on` | yes | no |
|
||||
| neither (`normal`) | `don't ask on` | yes | no |
|
||||
|
||||
Every mode ends its status bar with `(shift+tab to cycle)`, so `shift+tab` is the one
|
||||
token that means "the composer is up" regardless of mode, and it is space-free, which is
|
||||
what makes it survive the TUI stream. Matching `bypass` instead reports a perfectly
|
||||
healthy non-default worker as broken after burning the full ladder.
|
||||
|
||||
⚠️ **`shift+tab` contains a `+`, so it MUST go through `--data-urlencode`.** In a
|
||||
hand-built query the `+` decodes to a space and the server searches for `shift tab`,
|
||||
which never appears (measured: `matched:false`, and the response echoes back
|
||||
`match: "shift tab"`, which is how you spot it).
|
||||
|
||||
Stage 4 stays as the last resort for the case where even that misses: a worker that
|
||||
answers a trivial prompt **is** ready, whatever its statusline reads.
|
||||
|
||||
```bash
|
||||
# ALWAYS check .success: on failure `.data.sessionId` is null, jq -r prints the string
|
||||
# "null", and the flow below then burns its full readiness budget against
|
||||
# /api/v1/sessions/null before reporting jq noise instead of the actual cause.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
if [ -z "$SID" ]; then
|
||||
# SESSION_BUSY here is the 50-session cap, not the waiter cap; FORBIDDEN/CONFLICT/
|
||||
# OPERATION_FAILED/INVALID_INPUT are the others. None are retryable in a loop.
|
||||
jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed; stopping."
|
||||
exit 1
|
||||
fi
|
||||
for _ in $(seq 1 30); do # bounded: a bad SID would otherwise poll forever
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
done
|
||||
# ⚠️ pid != null proves STARTUP only, never life: a worker that later dies inside
|
||||
# its pane keeps status "idle" and a pid (the local tmux attach client, not the
|
||||
# worker). The death check is wait?until=exit, below.
|
||||
SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$
|
||||
# stage 1-3: `shift+tab` is the composer's status bar in EVERY permission mode (see the
|
||||
# table above), so this works whatever `claudeMode` the server runs. Single-token
|
||||
# matches only: TUI text is space-less. The `+` needs --data-urlencode.
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
# composer never appeared → the trust dialog is probably still up; accept it once
|
||||
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
|
||||
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
|
||||
SEQ=$((SEQ+1))
|
||||
fi
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
|
||||
fi
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
# stage 4, last resort: the composer never appeared at all. A miss is still not proof
|
||||
# of a broken worker, and answering is proof that it works. Split the token (your keystrokes echo
|
||||
# into the stream) and keep it unique per call. This costs the worker one turn, so
|
||||
# it runs only after the fast path missed. It must stay AFTER stage 2, which is the
|
||||
# only thing that clears the trust dialog: free text plus \r into a dialog still up
|
||||
# answers it blind, which is the same footgun as the up-front Enter.
|
||||
TOK="${RANDOM}_$$"
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
|
||||
SEQ=$((SEQ+1))
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=READY_$TOK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000' \
|
||||
| jq -e '.data.wait.matched' >/dev/null \
|
||||
|| echo "worker $SID never became ready; inspect terminal?tail="
|
||||
fi
|
||||
```
|
||||
|
||||
**Send a prompt and wait for the turn to finish** (claude workers — the call to
|
||||
prefer). It registers the waiter *before* typing, closing the race where a separate
|
||||
wait sees the previous turn's idle state. Loop by resending the **identical** request:
|
||||
the repeat is a tagged duplicate (same `clientId`+`seq`) that does not retype but
|
||||
answers from the session's current state. Verified: the stop hook resolves this in
|
||||
seconds; a duplicate resend answers in ~20 ms without retyping.
|
||||
|
||||
```bash
|
||||
for TRY in $(seq 1 10); do # BOUNDED: a \r-less send never produces a signal and resends are no-op duplicates
|
||||
R=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"run the tests, then summarize in one line\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ',"wait":true,"waitTimeout":60000}')
|
||||
if jq -e '.data.wait.timedOut' <<<"$R" >/dev/null; then
|
||||
[ "$TRY" = 2 ] && "${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
|
||||
| jq -r '.data.terminalBuffer' | tail -5 # two straight timeouts: prompt sitting unsubmitted?
|
||||
continue
|
||||
fi
|
||||
# Resolved — but a duplicate answering immediately reports the session's CURRENT
|
||||
# state ("it is idle now"), NOT that a new turn ran. A \r-less send lands exactly
|
||||
# here on try 2 (verified live), so check the terminal before believing it:
|
||||
if jq -e '.data.duplicate and .data.wait.immediate' <<<"$R" >/dev/null; then
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" | jq -r '.data.terminalBuffer' | tail -5
|
||||
# your prompt still on the ❯ composer line = never submitted (missing \r);
|
||||
# submit it with {"input":"\r"} (the only recovery), then loop again
|
||||
fi
|
||||
break
|
||||
done
|
||||
SEQ=$((SEQ+1)); jq '.data.wait.signal, .data.status' <<<"$R"
|
||||
```
|
||||
|
||||
Read the outcome in this order: `wait.signal != null` → done (`stop` is definitive;
|
||||
`idle` is heuristic) — **unless** it arrived as `duplicate:true` + `immediate:true`,
|
||||
which only says the session is idle *now* and must be confirmed from the terminal
|
||||
(above); `wait.timedOut` → loop again (bounded); `wait.ended` → session gone, stop.
|
||||
If the loop exhausts its cap, do not keep looping: read the terminal, report what
|
||||
you see, and remember that a still-typed-but-unsubmitted prompt (missing `\r`) can
|
||||
only be recovered by submitting it with `{"input":"\r"}`.
|
||||
|
||||
**Shell worker + completion marker** — the pattern for `shell` mode (no hooks there).
|
||||
The typed line must not contain the marker verbatim (the input echo would match
|
||||
instantly — observed live), so build it with a variable the worker's shell expands:
|
||||
|
||||
```bash
|
||||
N="${RANDOM}_$$"; MARK="DONE_$N" # unique per call: tmux repaints replay old text
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}'
|
||||
SEQ=$((SEQ+1))
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=$MARK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=120000' \
|
||||
| jq -r '.data.wait | {matched, snippet}'
|
||||
```
|
||||
|
||||
The typed line shows `${M}_…`, the real output shows `DONE_… rc=<exit code>`, and the
|
||||
snippet carries the exit code back to you.
|
||||
|
||||
**Read a worker's answer.** For `claude` and `codex` workers this is the read path:
|
||||
`last-response` returns the agent's final message as clean text, taken from the
|
||||
transcript rather than the screen, so it carries none of the TUI's box-drawing or
|
||||
repaint noise.
|
||||
|
||||
```bash
|
||||
for _ in $(seq 1 10); do # the transcript write LAGS the stop signal
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
```
|
||||
|
||||
`.data` is `{text, timestamp}`. ⚠️ **Poll it, do not read it once.** `text` is written
|
||||
from the transcript file, which is flushed slightly *after* the `stop` hook fires, so a
|
||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, verified live), which is
|
||||
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer there, tail in **bytes**
|
||||
(`textOutput` in `GET .../output` stays empty for interactive sessions; don't use it):
|
||||
|
||||
```bash
|
||||
# \x1b is a GNU-sed extension: BSD sed (macOS) matches it as a literal "x1b", so the
|
||||
# same one-liner strips NOTHING there and hands you raw ANSI. Feed sed a real ESC.
|
||||
ESC=$(printf '\033')
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=3000" | jq -r '.data.terminalBuffer' \
|
||||
| sed -e "s/${ESC}\[[0-9;?]*[a-zA-Z]//g" -e "s/${ESC}([B0]//g" | grep -v '^[[:space:]]*$' | tail -30
|
||||
```
|
||||
|
||||
⚠️ Do not use that pipeline to read a **claude/codex** answer. A full-screen TUI draws
|
||||
with cursor moves, so the stripped buffer is largely one long line: `tail -30` has
|
||||
almost nothing to split on and you get a wall of repaint noise with the answer buried
|
||||
in it (verified live, side by side with `last-response` returning the exact prose).
|
||||
The terminal buffer is for *diagnosis* (is my prompt sitting unsubmitted?), not for
|
||||
reading answers. Avoid `?full=1` (entire tmux scrollback, a context bomb) unless doing
|
||||
a post-mortem.
|
||||
|
||||
**Detect a dead worker cheaply**: `GET .../wait?until=exit&timeout=60000` answers
|
||||
immediately (`signal:"exit"`, `immediate:true`) if the PTY is gone — including a
|
||||
worker that exited *inside* its pane, which `GET .../sessions/:id` keeps reporting
|
||||
as `status:"idle"` with a pid (that pid is the local tmux attach client, not the
|
||||
worker). The wait routes are the only liveness check; a worker dying while a wait
|
||||
is parked resolves it within ~3 s. A session deleted mid-wait resolves in ~1 s.
|
||||
|
||||
**Clean up** — only ids you created, one at a time, always through the §0 helper:
|
||||
|
||||
```bash
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
Everything else (endpoint tables, per-mode signal table, error codes, capacity
|
||||
limits, Docker/remote caveats): [reference/endpoints.md](reference/endpoints.md).
|
||||
Fan-out orchestration and blocked-worker handling:
|
||||
[reference/recipes.md](reference/recipes.md).
|
||||
@@ -0,0 +1,284 @@
|
||||
# Codeman API reference for agents
|
||||
|
||||
Loaded on demand from the `codeman` skill. Assumes the guard variables from SKILL.md
|
||||
(`$API`, `$SELF`, `"${CURL[@]}"`). Canonical contract: `docs/api-reference.md` in the
|
||||
Codeman repo; this file is the agent-relevant subset, verified live.
|
||||
|
||||
## Envelope and errors
|
||||
|
||||
Every JSON response: `{"success":true,"data":…}` or
|
||||
`{"success":false,"error":"…","errorCode":"…"}`. Branch on `errorCode`:
|
||||
|
||||
| `errorCode` | HTTP | Meaning |
|
||||
|-------------|------|---------|
|
||||
| `INVALID_INPUT` | 400 | malformed request; the message names the bad field |
|
||||
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope — `jq` dies with a parse error, see the guard in SKILL.md |
|
||||
| `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) |
|
||||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: the 50-session cap is full, so clean up before starting more |
|
||||
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
|
||||
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
|
||||
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full — back off; switching sessions will not help |
|
||||
| `INTERNAL_ERROR` | 500 | server bug |
|
||||
|
||||
`SESSION_BUSY` vs `RATE_LIMITED` on the wait endpoints is deliberate: the first means
|
||||
"too many waiters on *this* session", the second means the *pool* is full.
|
||||
|
||||
⚠️ **The guards that run before any handler answer in PLAIN TEXT, not this envelope**,
|
||||
so `jq` reports a parse error and `.errorCode` is simply absent. All of them:
|
||||
`401 Unauthorized` (Basic auth, carries `WWW-Authenticate`), `401 Unauthorized: hook
|
||||
secret required`, `403 Forbidden: host not allowed` (Host allowlist), `403 Forbidden:
|
||||
cross-site request blocked` (Origin/CSRF guard), and the auth rate limiter's
|
||||
`429 Too Many Requests` (with `Retry-After`; distinct from the JSON `RATE_LIMITED`
|
||||
above, which is the waiter pool). When a call returns something `jq` cannot parse,
|
||||
read the status with `-w '%{http_code}'` and the raw body before assuming a bug.
|
||||
|
||||
## Sessions
|
||||
|
||||
| Task | Call |
|
||||
|------|------|
|
||||
| list sessions (metadata only, ~1.5 KB each, safe to poll) | `GET /api/v1/sessions` |
|
||||
| one session (has `.data.pid`, `null` until the PTY spawns) | `GET /api/v1/sessions/:id` — ⚠️ **neither a liveness nor a busy check**, see below |
|
||||
| unified list incl. history | `GET /api/v1/sessions/unified` → `.data.sessions[]` (NOT `.data[]`), and it folds in transcript history from the whole machine — never use it to verify cleanup; `GET /api/v1/sessions` is the cleanup check |
|
||||
| start case + session in one call | `POST /api/v1/quick-start` |
|
||||
| send input | `POST /api/v1/sessions/:id/input` |
|
||||
| **read a worker's answer** (claude/codex) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}` — clean transcript text, no TUI noise. ⚠️ **Poll it**: the transcript flush lags the `stop` signal, so a read taken the instant send-and-wait returns is `""` (verified live). Also `""` before the first completed turn, and always `""` for `shell`/`opencode`/`gemini`/`antigravity` (no transcript) |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer` — for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
| background agents, global list | `GET /api/v1/subagents` (admin-only in multi-user mode) |
|
||||
| server status / version | `GET /api/v1/status` → `.data.version` |
|
||||
| delete one session (yours only, via `delete_session`) | `DELETE /api/v1/sessions/:id` — never call it bare; the fail-closed helper in SKILL.md §0 is the only self-protection that exists. Answers `{"success":true,"data":{}}`: an **empty** body is the success signal, there is nothing to read back |
|
||||
|
||||
`DELETE /api/v1/sessions/:id` takes one undocumented query parameter, `killMux`, and
|
||||
it defaults to `true` (anything other than the exact string `false` means kill). With
|
||||
`?killMux=false` the call **detaches instead of killing**: the tmux session and the
|
||||
agent inside it keep running, the session drops out of `GET /api/v1/sessions` so it
|
||||
looks deleted, and it is deliberately left in persisted state for recovery (the
|
||||
lifecycle log records `detached`, not `deleted`). That is the wrong tool for agent
|
||||
cleanup: your worker keeps burning tokens where neither you nor the user can see it,
|
||||
and the list you would check to confirm cleanup shows it gone. Delete plainly, and let
|
||||
`killMux` default.
|
||||
|
||||
⚠️ **`.data.status` is a heuristic and is often simply wrong. Never branch on it.**
|
||||
Measured on a live claude worker: `status` read `idle` while the worker was mid-turn
|
||||
and actively producing output, with `lastActivityAt` equal to the moment of the call.
|
||||
It is wrong in both directions, so neither value tells you anything you can act on:
|
||||
|
||||
- **`idle` does not mean finished.** Use `stop` (the definitive end-of-turn hook) via
|
||||
send-and-wait, or an output marker. If you must judge from outside, sample
|
||||
`terminal?tail=` twice a few seconds apart and compare: a changing buffer is the
|
||||
only cheap positive proof that a worker is still working.
|
||||
- **`idle` does not mean alive.** A worker that dies inside its pane keeps
|
||||
`status:"idle"` and a pid (that pid is the local tmux attach client, not the
|
||||
worker). `wait?until=exit` is the death check.
|
||||
|
||||
Treat `status` as a UI hint. Every synchronization decision in these recipes is built
|
||||
on signals and markers for exactly this reason.
|
||||
|
||||
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
|
||||
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
|
||||
JSON-stream path). Verified empty on live claude and shell sessions. Use
|
||||
`last-response` for claude/codex answers; only fall back to `terminal?tail=` for
|
||||
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
||||
|
||||
```bash
|
||||
# `\x1b` is a GNU-sed extension. BSD sed (macOS, the default there) reads it as a
|
||||
# literal "x1b", matches nothing, and hands back raw ANSI, silently. Feed sed a real
|
||||
# ESC byte instead; that form works on GNU and BSD alike.
|
||||
ESC=$(printf '\033')
|
||||
… | jq -r '.data.terminalBuffer' | sed -e "s/${ESC}\[[0-9;?]*[a-zA-Z]//g" -e "s/${ESC}([B0]//g"
|
||||
```
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||
— `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity`; response is
|
||||
`.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.
|
||||
|
||||
⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field
|
||||
is absent, `jq -r` prints the literal string `null`, and every later call then targets
|
||||
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
|
||||
instead of the real cause. Failure modes here are `SESSION_BUSY` (the **50-session
|
||||
cap**, not the waiter cap), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and
|
||||
`INVALID_INPUT`; none of them are retryable in a loop.
|
||||
|
||||
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
|
||||
to match a case the user linked in lands in that **real repo**, not a fresh scratch
|
||||
directory. Pick distinctive scratch names, and use a linked name deliberately when you
|
||||
do want a worker in an existing checkout.
|
||||
|
||||
`POST /api/v1/sessions/:id/input` body:
|
||||
`{"input":"one line\r","useMux":true,"clientId":"agent-1","seq":1}` plus optionally
|
||||
`"wait"` / `"waitTimeout"` (below).
|
||||
|
||||
- ⚠️ **The input must contain `\r`** (the JSON escape, i.e. a real carriage return)
|
||||
**or Enter is never sent**: the text is typed onto the worker's prompt and sits
|
||||
there unsubmitted. Verified live — this is the number-one silent failure, and no
|
||||
response field catches it: `delivered:true` means "written to the pane", not
|
||||
"submitted". A `\r`-less send with `wait` reports `delivered:true` and then every
|
||||
wait on that turn times out. Without `wait`, fire-and-forget returns an **empty**
|
||||
`{"success":true,"data":{}}` — no `delivered`, no `duplicate`; those fields exist
|
||||
only on the `wait` variant, so a fire-and-forget flow gets no delivery
|
||||
confirmation at all.
|
||||
- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
|
||||
a dialog), send `{"input":"\r"}`.
|
||||
- `input` is capped at **100 000 characters**; one character over is a 400
|
||||
`INVALID_INPUT` and **nothing is typed** (the schema rejects the whole body, so it
|
||||
is not a truncation). Since the value is one line anyway, a prompt that big means
|
||||
you are pasting a file into the composer: write it to disk in the worker's case
|
||||
directory and send a path instead. `clientId` is capped at 128 characters on the
|
||||
same terms.
|
||||
- `clientId`+`seq` give exactly-once delivery: the server applies each pair at most
|
||||
once. Increment `seq` per new input.
|
||||
|
||||
## The wait primitives
|
||||
|
||||
Three bounded long-polls. Shared semantics:
|
||||
|
||||
- **Timeout = HTTP 200** with `wait.timedOut:true`. Loop over short waits (60 s);
|
||||
`tailscale serve` / cloudflared cut idle connections.
|
||||
- Timeouts are **clamped** to `[1000, 600000]` ms (operator-tunable); the applied
|
||||
value is echoed as `wait.timeoutMs` — read it back, never assume.
|
||||
- ⚠️ Clamping only covers **positive integers**. `timeout=0`, a negative value, a
|
||||
fraction (`timeout=1500.5`) and anything non-numeric (`timeout=30s`) are rejected by
|
||||
the schema as a 400 `INVALID_INPUT` naming the field, not silently clamped up to
|
||||
the floor. Omit the parameter to take the 60 000 ms default; never send a computed
|
||||
remainder without rounding it and checking it is still above zero. Same rule for
|
||||
`waitTimeout` in the input body, where the value must additionally be a JSON number
|
||||
(a quoted `"60000"` is a 400).
|
||||
- All three nest the result under `.data.wait`, same shape, so one helper parses all.
|
||||
- `.data.status` (post-wait `SessionStatus`) and `.data.limitPaused` ride along.
|
||||
`limitPaused:true` means the session is paused on a usage limit and will emit
|
||||
nothing until reset — a timeout is then *expected*; do not retry hard, and do not
|
||||
kill the worker.
|
||||
|
||||
### Signals by mode
|
||||
|
||||
| Signal | Meaning | Available for |
|
||||
|--------|---------|---------------|
|
||||
| `idle` | output stabilized + prompt detected — heuristic, can flap mid-turn | every mode |
|
||||
| `working` | session started producing output | every mode |
|
||||
| `stop` | Claude Code `stop` hook — the definitive end-of-turn | `claude` only |
|
||||
| `blocked` | `permission_prompt` / `elicitation_dialog` hook — the worker needs an answer | `claude` only |
|
||||
| `exit` | PTY exited or session deleted | every mode |
|
||||
|
||||
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
|
||||
`stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
|
||||
`["idle","exit"]` on shell); requesting them *explicitly* there is a 400 naming the
|
||||
mode. ⚠️ On hook-less modes the lifecycle signals are also **coarse in practice**: a
|
||||
short shell command produced **no** `idle` transition within 60 s (verified live), so
|
||||
a `fresh=1` / fresh-delivery wait can burn its whole timeout while the work finished
|
||||
long ago. Synchronize hook-less modes with `wait-output` markers instead.
|
||||
|
||||
Two more places hooks go missing even in claude mode: **Docker cases** need
|
||||
`CODEMAN_DOCKER_BRIDGE_HOOKS=1` on the server (without it only `idle`/`working`/
|
||||
`exit` arrive), and **remote-SSH cases** run the agent on another host whose hooks may
|
||||
never reach this server. When unsure, ask for `stop,idle,exit`.
|
||||
|
||||
⚠️ **Signals are edge-triggered with no history.** A signal that fires while no
|
||||
waiter is registered is gone; no later wait can observe it (`until=stop` on a worker
|
||||
whose turn already ended just times out, with or without `fresh` — verified live).
|
||||
Register the waiter before the event can happen: send-and-wait does exactly that,
|
||||
and `wait-output` markers with `from=buffer` are latched by construction. Never
|
||||
fire-and-forget N prompts and then gather signal-waits worker by worker; every
|
||||
worker that finishes before its gather is unobservable (see recipes.md Flow 3b).
|
||||
|
||||
### `GET /api/v1/sessions/:id/wait`
|
||||
|
||||
| Param | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `until` | `stop,idle,exit` | comma list; unknown token → 400 naming it |
|
||||
| `timeout` | 60000 | ms, positive integer only (0/negative/fractional = 400); clamped, applied value echoed as `wait.timeoutMs` |
|
||||
| `fresh` | `0` | `1` requires an actual *transition*, ignoring the state at call time |
|
||||
|
||||
⚠️ A session whose PTY has not spawned (`pid:null`) or has exited counts as `exit`
|
||||
**right now**: with the default set the call answers immediately
|
||||
(`signal:"exit", immediate:true`). That is how you detect a dead worker cheaply — but
|
||||
it also means "wait for my just-created session" needs the readiness recipe in
|
||||
SKILL.md, not this endpoint.
|
||||
|
||||
### `GET /api/v1/sessions/:id/wait-output`
|
||||
|
||||
| Param | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `match` | required | literal substring, 1–200 chars, ANSI-stripped; chunk-straddling matches found; **no regex** — a `regex=` param is a 400 |
|
||||
| `nocase` | `0` | case-insensitive compare; snippet keeps original casing |
|
||||
| `from` | `now` | `buffer` scans the tail (~256 KB) of existing output first |
|
||||
| `timeout` | 60000 | same clamp, same positive-integer rule |
|
||||
|
||||
Four traps, all observed live:
|
||||
|
||||
1. **The echo of your own typed command is output.** A marker appearing verbatim in
|
||||
the input line matches the moment the text is typed, before the command runs.
|
||||
Split the marker with a shell variable: send `M=DONE; …; echo ${M}_1234\r`, wait
|
||||
on `DONE_1234`.
|
||||
2. **`from=now` misses text printed before the wait landed** — a marker echoed just
|
||||
before the request registered timed out at full length. After sending a command,
|
||||
always wait with `from=buffer`.
|
||||
3. **`from=now` can also match too much**: tmux repaints old screen content as
|
||||
ordinary output on attach/resize/redraw, so a *generic* marker (`BUILD OK`)
|
||||
matches stale text. Unique-per-call markers (`DONE_$RANDOM`) make both `from`
|
||||
modes safe.
|
||||
4. **TUI output can be space-less in the stream.** Full-screen TUIs (claude, codex,
|
||||
…) position words with cursor-movement escapes rather than literal spaces, so
|
||||
the stripped stream can read `Yes,Itrustthisfolder` while the pane shows the
|
||||
spaced phrase. Whether a given phrase keeps its spaces depends on how the TUI
|
||||
drew it (observed live: some multi-word matches fire, some never do), so treat
|
||||
multi-word matches against TUI screens as unreliable and match a **single
|
||||
space-free token** (`trust`, `shift+tab`). Plain command output (shell workers,
|
||||
`echo` lines) keeps real spaces and multi-word matches work there.
|
||||
|
||||
Build the query with `-G --data-urlencode` (a `+` in a hand-built query decodes to a
|
||||
space). Result extras: `wait.matched`, `wait.match`, `wait.snippet` (bounded window
|
||||
around the match, blank runs collapsed — the snippet is often all you need to read).
|
||||
|
||||
### `POST /api/v1/sessions/:id/input` with `wait`
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `wait` | `true` (default signal set) or the same comma grammar as `until`; absent = historical fire-and-forget |
|
||||
| `waitTimeout` | ms, same clamp; a JSON number, positive integer (`"60000"` is a 400) |
|
||||
|
||||
Registers the waiter **before** typing, which closes the race where send-then-wait
|
||||
sees the previous turn's idle state and returns instantly. Response adds `delivered`
|
||||
and `duplicate` beside the standard `wait` object.
|
||||
|
||||
A **tagged duplicate** (same `clientId`+`seq` already applied) does not retype but
|
||||
still honors `wait`, answering from the session's *current* state instead of
|
||||
requiring a new transition (`delivered:false, duplicate:true` — verified: ~20 ms,
|
||||
command ran exactly once). That is what makes the resend-identical-request loop in
|
||||
SKILL.md correct: iteration 1 delivers and needs a transition; later iterations
|
||||
resolve immediately if the turn ended in between. ⚠️ The flip side: a duplicate's
|
||||
`immediate:true` answer is the current state and nothing more — an idle worker
|
||||
whose prompt was never submitted (missing `\r`) produces the same
|
||||
`signal:"idle", immediate:true` as one that finished the turn. Confirm from
|
||||
`terminal?tail=` before reporting success; SKILL.md's loop shows where.
|
||||
|
||||
### Outcome parsing, in order
|
||||
|
||||
1. `wait.signal != null` (or `wait.matched == true`) — the thing happened.
|
||||
`wait.immediate:true` rides along and means the condition already held at call
|
||||
time; if that is not what you meant, you wanted `fresh=1` or send-and-wait.
|
||||
2. `wait.timedOut` — poll boundary; loop again.
|
||||
3. `wait.ended` — session deleted/torn down mid-wait; stop looping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---------|-------------|
|
||||
| every curl fails with a certificate error | you dropped `-k`; `CODEMAN_API_URL` is HTTPS with a self-signed cert |
|
||||
| `jq: parse error` on every call | plain-text 401s: the server has a password. Check with `-w '%{http_code}'`, use the guard's `.env` fallback, and if no `.env` exists, stop and ask the user for credentials |
|
||||
| input arrives but nothing happens; later waits all time out | the input had no `\r`, so Enter was never sent; the text is sitting on the worker's prompt. **Submitting it with `{"input":"\r"}` is the ONLY recovery** — Ctrl+U (0x15) and Esc do NOT clear the composer (verified live) — and the flush costs one turn in which the worker reasons about the junk; open the next real prompt with "ignore the garbled line above:" |
|
||||
| `GET .../sessions/$CODEMAN_SESSION_ID` 404s | Docker case: the env id is truncated to 8 chars; find yourself with `startswith($SELF)`, and always self-compare by prefix, in both directions |
|
||||
| `CODEMAN_MUX` unset but you seem to be in a session | remote-SSH case: the env vars are not exported there. Fail closed — refuse to act |
|
||||
| connection refused from inside a container | a loopback-bound server is unreachable from a container, and `CODEMAN_DOCKER_BRIDGE_HOOKS=1` does **not** fix that: it opens a hooks-only listener, so hook events start flowing but `/api/v1/*` stays refused. Driving the API from inside a Docker case needs a reachable bind (an operator decision); report it, don't retry |
|
||||
| wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare) — poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server |
|
||||
| wait on `stop` never resolves | non-claude mode, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
|
||||
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept missed; use the readiness recipe in SKILL.md (wait for `shift+tab` first, accept the dialog only as the bounded fallback) |
|
||||
| readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the mode is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). ⚠️ It must go through `--data-urlencode`, or the `+` decodes to a space and you silently search for `shift tab`. Expect `blocked` signals mid-turn on the non-default modes |
|
||||
| ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above |
|
||||
| `wait-output` times out although the pane shows the text | multi-word match against a TUI screen; the stream has no spaces there — match one token |
|
||||
| `wait-output` matched instantly with stale text | generic marker + tmux repaint; use `DONE_$RANDOM` |
|
||||
| 409 `SESSION_BUSY` on a wait | too many concurrent waiters on that session (cap 16 combined); reuse one wait per worker |
|
||||
| 429 `RATE_LIMITED` on a wait | global/owner waiter pool full; back off, do not switch sessions |
|
||||
@@ -0,0 +1,302 @@
|
||||
# Worked orchestration flows
|
||||
|
||||
Loaded on demand from the `codeman` skill. Every flow assumes the SKILL.md §0 preamble
|
||||
is in scope (`$API`, `$SELF`, `$CID`, `"${CURL[@]}"`, `delete_session`).
|
||||
|
||||
⚠️ **That preamble does not survive between tool calls**, so re-run it at the top of
|
||||
every Bash call that uses these flows, in full. Re-pasting only part of it is the
|
||||
failure mode the fail-closed `delete_session` exists to contain, and a `clientId` you
|
||||
rebuild from `$$` changes per call, which turns the duplicate-resend loop in Flow 1
|
||||
into a second typed prompt.
|
||||
|
||||
Track every session id you create; delete them (and only them) when done. The two
|
||||
silent killers: **every input ends with `\r`**, and **markers must be split** so the
|
||||
typed-line echo does not match them.
|
||||
|
||||
## Flow 1: claude worker, end to end
|
||||
|
||||
Start a worker, get it truly ready (trust dialog included), give it a task, wait for
|
||||
the turn to finish, read the answer, clean up. Verified live: the stop hook resolves
|
||||
the send-and-wait within seconds of the turn ending.
|
||||
|
||||
```bash
|
||||
# 1. start (returns before the CLI inside is ready). ALWAYS check .success: on failure
|
||||
# .data.sessionId is null, jq -r yields the string "null", and every step below
|
||||
# then runs against /api/v1/sessions/null and reports jq noise, not the cause.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-tests","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed"; exit 1; }
|
||||
CREATED+=("$SID") # the cleanup list
|
||||
SEQ=1 # $CID is the fixed literal from §0; never rebuild it from $$
|
||||
|
||||
# 2. readiness. "wait for idle" or "wait for ❯" is NOT readiness: a fresh session
|
||||
# reports idle before anything spawned, and the first-run trust dialog contains ❯.
|
||||
# Codeman CAN auto-accept that dialog, but the accept misses on some runs (both
|
||||
# outcomes seen live), so: composer marker first, dialog only as the bounded
|
||||
# fallback (a blind Enter up front would land in an already-ready composer).
|
||||
# Stage 1 is SHORT on purpose: an already-trusted case matches in <1 s, while a
|
||||
# virgin case can never pass it (the dialog is up) and always pays it in full —
|
||||
# the long budget belongs to stage 3, after the dialog is answered.
|
||||
# Single-token matches only: TUI text is space-less in the stream.
|
||||
# ⚠️ `bypass` is the statusline of ONE permission mode (the default one Codeman
|
||||
# spawns). The server's `claudeMode` setting also has auto/allowedTools/normal
|
||||
# spawns whose statusline differs, and the mode is not exposed on GET
|
||||
# /api/v1/sessions/:id. `shift+tab` is the one token EVERY mode's status bar ends
|
||||
# with ('(shift+tab to cycle)'), measured per mode, so match that and not `bypass`.
|
||||
# The `+` needs --data-urlencode or it decodes to a space. Stage 4 remains the last
|
||||
# resort: proving readiness by making the worker answer rather than by chrome.
|
||||
for _ in $(seq 1 30); do
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
done
|
||||
# (pid != null proves startup only — a worker that later dies inside its pane keeps
|
||||
# status "idle" and a pid. The death check is wait?until=exit.)
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
|
||||
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
|
||||
SEQ=$((SEQ+1))
|
||||
fi
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
|
||||
fi
|
||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||
# stage 4, mode-agnostic and bounded: answering a trivial prompt IS readiness.
|
||||
# Costs the worker one turn, so it only runs when the fast marker missed. Split
|
||||
# token (the typed line echoes into the stream) and unique per call. Must stay AFTER
|
||||
# the dialog fallback: free text plus \r into a trust dialog still up answers it
|
||||
# blind, the same footgun as an up-front Enter.
|
||||
TOK="${RANDOM}_$$"
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
|
||||
SEQ=$((SEQ+1))
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=READY_$TOK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000' \
|
||||
| jq -e '.data.wait.matched' >/dev/null || echo "worker $SID not ready; inspect terminal?tail="
|
||||
fi
|
||||
|
||||
# 3. send-and-wait, looping on the IDENTICAL request (tagged duplicate: no retype).
|
||||
# BOUNDED (a \r-less send would otherwise loop forever), body built with jq -n so
|
||||
# quotes/backslashes/$ in a real prompt survive; note the appended \r.
|
||||
PROMPT='run the unit tests and summarize failures in one line'
|
||||
BODY=$(jq -n --arg p "$PROMPT" --arg c "$CID" --argjson s "$SEQ" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:60000}')
|
||||
for TRY in $(seq 1 10); do
|
||||
R=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$BODY")
|
||||
if jq -e '.data.wait.timedOut' <<<"$R" >/dev/null; then
|
||||
jq -e '.data.limitPaused' <<<"$R" >/dev/null && sleep 60 # usage-limit pause: silence is expected
|
||||
[ "$TRY" = 2 ] && "${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
|
||||
| jq -r '.data.terminalBuffer' | tail -5 # is the prompt sitting unsubmitted?
|
||||
continue
|
||||
fi
|
||||
# Resolved — but duplicate + immediate is only "the session is idle NOW", which a
|
||||
# never-submitted (\r-less) prompt also produces. Check before believing it:
|
||||
if jq -e '.data.duplicate and .data.wait.immediate' <<<"$R" >/dev/null; then
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
|
||||
| jq -r '.data.terminalBuffer' | tail -5
|
||||
# prompt still on the ❯ composer line = never submitted; {"input":"\r"} is the
|
||||
# only recovery, then loop again
|
||||
fi
|
||||
break
|
||||
done
|
||||
SEQ=$((SEQ+1))
|
||||
|
||||
# 4. interpret
|
||||
case "$(jq -r '.data.wait.signal' <<<"$R")" in
|
||||
stop) : ;; # definitive end of turn
|
||||
idle) : ;; # heuristic — and if it rode a duplicate with
|
||||
# immediate:true, it proves nothing ran (step 3)
|
||||
exit) echo "worker died" ;;
|
||||
null) jq -e '.data.wait.ended' <<<"$R" >/dev/null && echo "worker deleted mid-wait" ;;
|
||||
esac
|
||||
|
||||
# 5. read the answer. For a claude worker this is last-response: clean transcript text,
|
||||
# no TUI repaint noise. Do NOT scrape the terminal for this — a full-screen TUI
|
||||
# draws with cursor moves, so the stripped buffer is nearly one long line and the
|
||||
# answer arrives buried in redraw garbage.
|
||||
# POLL it: the transcript flush lags the stop signal, so a single read taken the
|
||||
# instant step 3 returned comes back "" even though the turn finished (verified live).
|
||||
for _ in $(seq 1 10); do
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity, which have no transcript — use
|
||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||
|
||||
# 6. clean up — exact id, own list only, through the fail-closed §0 helper
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
|
||||
re-ask about the same delivery (the duplicate-wait loop above).
|
||||
|
||||
## Flow 2: shell worker running a build, marker-synchronized
|
||||
|
||||
`shell` sessions have no hooks (`stop`/`blocked` are a 400 there), and their lifecycle
|
||||
signals are coarse — a short command may emit no `idle` transition at all (verified
|
||||
live), so send-and-wait can burn its whole timeout. The reliable pattern is a split,
|
||||
unique marker plus `wait-output from=buffer`:
|
||||
|
||||
```bash
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"builder","mode":"shell"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed"; exit 1; }
|
||||
CREATED+=("$SID")
|
||||
for _ in $(seq 1 30); do
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
done
|
||||
|
||||
# Split marker: the typed line carries ${M}_N, only the OUTPUT carries DONE_N.
|
||||
# An unsplit marker matches the echo of your own keystrokes before the build runs.
|
||||
N="${RANDOM}_$$"; MARK="DONE_$N"
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"codeman-build-1","seq":1}'
|
||||
|
||||
for TRY in $(seq 1 30); do # BOUNDED (30 min): a \r-less send makes an uncapped loop infinite
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode "match=$MARK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000')
|
||||
jq -e '.data.wait.matched' <<<"$R" >/dev/null && break
|
||||
jq -e '.data.wait.ended' <<<"$R" >/dev/null && { echo "worker gone"; break; }
|
||||
[ "$TRY" = 2 ] && "${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
|
||||
| jq -r '.data.terminalBuffer' | tail -5 # command still sitting unsubmitted?
|
||||
done
|
||||
jq -r '.data.wait.snippet' <<<"$R" # e.g. "DONE_123_456 rc=0" — the exit code rides the marker line
|
||||
```
|
||||
|
||||
## Flow 3: fan out N workers, gather as each finishes
|
||||
|
||||
Start everything first, then gather. One in-flight wait per worker — the per-session
|
||||
waiter cap is 16 and abandoned concurrent waits pile up against it.
|
||||
|
||||
```bash
|
||||
declare -A WORKER MARKS
|
||||
for task in lint typecheck unit; do
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"fan-'"$task"'","mode":"shell"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "$task: spawn failed"; continue; }
|
||||
WORKER[$task]=$SID; CREATED+=("$SID")
|
||||
done
|
||||
for task in "${!WORKER[@]}"; do
|
||||
SID=${WORKER[$task]}
|
||||
for _ in $(seq 1 30); do
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
done
|
||||
N="${task}_${RANDOM}"; MARKS[$task]="DONE_$N"
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"M=DONE; npm run '"$task"'; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"codeman-fan-'"$task"'","seq":1}'
|
||||
done
|
||||
for task in "${!WORKER[@]}"; do # sequential gather; each wait blocks until that worker is done
|
||||
for TRY in $(seq 1 30); do # BOUNDED per worker, same reasoning as Flow 2
|
||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/${WORKER[$task]}/wait-output" \
|
||||
--data-urlencode "match=${MARKS[$task]}" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000')
|
||||
jq -e '.data.wait.matched or .data.wait.ended' <<<"$R" >/dev/null && break
|
||||
done
|
||||
echo "$task: $(jq -r '.data.wait.snippet // "worker gone"' <<<"$R" | tail -1)"
|
||||
done
|
||||
```
|
||||
|
||||
## Flow 3b: fan out N CLAUDE workers
|
||||
|
||||
Send-and-wait is synchronous, so the shell-flow shape ("send everything, then
|
||||
gather") does not translate directly: the send *is* the wait, and worker 2's prompt
|
||||
would not go out until worker 1's turn ended. Two working patterns, both verified
|
||||
live (and one anti-pattern, measured failing, replaced by B):
|
||||
|
||||
**A. Background the send-and-waits** (simplest; each resolved on `stop` while the
|
||||
other was still running):
|
||||
|
||||
```bash
|
||||
sendwait() { # $1=sid $2=prompt $3=seq — assumes the worker passed Flow 1's readiness
|
||||
local body; body=$(jq -n --arg p "$2" --argjson s "$3" --arg c "codeman-fan-$1" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:600000}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$1/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$body" > "/tmp/fan-$1.json"
|
||||
}
|
||||
( sendwait "$SID1" 'refactor module A and reply DONE' 2 & \
|
||||
sendwait "$SID2" 'write tests for module B and reply DONE' 2 & wait )
|
||||
jq -c '.data.wait | {signal, waitedMs}' /tmp/fan-"$SID1".json /tmp/fan-"$SID2".json
|
||||
```
|
||||
|
||||
One in-flight wait per worker keeps you far from the 16-per-session waiter cap.
|
||||
|
||||
**B. Fire-and-forget, then gather with output markers.** If you must send every
|
||||
prompt before waiting on anything, do **not** gather with signal waits: signals
|
||||
are edge-triggered with no history, so a `stop` that fires before the gather
|
||||
reaches that worker is gone and unobservable afterwards — `fresh=1` cannot help,
|
||||
and neither can omitting it (measured: worker 2's turn ended at +2 s, its
|
||||
sequential `until=stop,exit&fresh=1` gather burned its full bounded 300 s and
|
||||
reported nothing). Gather instead on a marker each worker prints itself, which
|
||||
`from=buffer` re-finds no matter when it appeared:
|
||||
|
||||
```bash
|
||||
# SIDS[1], SIDS[2] = worker ids that already passed Flow 1's readiness.
|
||||
# The typed prompt must NOT contain the finished marker verbatim (your keystrokes
|
||||
# echo into the output stream and would match instantly), so ask for it in halves:
|
||||
declare -A TOK
|
||||
for i in 1 2; do
|
||||
TOK[$i]="${RANDOM}_$i"
|
||||
BODY=$(jq -n --arg p "do task $i; when completely done print the word WORKDONE immediately followed by _${TOK[$i]}" \
|
||||
--arg c "codeman-fan-$i" --argjson s 2 '{input:($p+"\r"),useMux:true,clientId:$c,seq:$s}')
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/${SIDS[$i]}/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$BODY"
|
||||
done
|
||||
for i in 1 2; do # order no longer matters: the marker is latched in the buffer
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/${SIDS[$i]}/wait-output" \
|
||||
--data-urlencode "match=WORKDONE_${TOK[$i]}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode 'timeout=600000' | jq -c '.data.wait | {matched, snippet}'
|
||||
done
|
||||
```
|
||||
|
||||
Use A unless you genuinely need to send everything before waiting on anything: A
|
||||
needs no marker discipline, and resolves on the definitive `stop` instead of on
|
||||
the worker remembering to print a token.
|
||||
|
||||
## Flow 4: watch for a worker stuck on a permission prompt
|
||||
|
||||
Claude workers can block on a permission dialog. `blocked` is a wait signal
|
||||
(claude-mode only), so watch for it and surface the question to the user instead of
|
||||
guessing an answer. Expect it routinely on a server whose `claudeMode` is not the
|
||||
default bypass one (the same setting that decides whether the readiness marker in
|
||||
Flow 1 ever appears):
|
||||
|
||||
```bash
|
||||
ESC=$(printf '\033') # \x1b is GNU-sed only; BSD sed (macOS) would strip nothing
|
||||
R=$("${CURL[@]}" "$API/api/v1/sessions/$SID/wait?until=stop,blocked,exit&timeout=60000")
|
||||
if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" | jq -r '.data.terminalBuffer' \
|
||||
| sed -e "s/${ESC}\[[0-9;?]*[a-zA-Z]//g" | grep -v '^[[:space:]]*$' | tail -15
|
||||
# show this to the user and ask how to answer; do NOT auto-confirm another
|
||||
# session's permission prompt
|
||||
fi
|
||||
```
|
||||
|
||||
## Cleanup discipline
|
||||
|
||||
At the end of the conversation (or on abort), delete exactly what you created:
|
||||
|
||||
```bash
|
||||
for id in "${CREATED[@]}"; do
|
||||
delete_session "$id"
|
||||
done
|
||||
```
|
||||
|
||||
- Only ids from your own `CREATED` list. Never enumerate `/api/v1/sessions` and
|
||||
delete by pattern; other sessions belong to the user.
|
||||
- Always go through `delete_session`. It refuses an empty id, refuses when `$SELF` is
|
||||
unset or too short to prove the target is not you, and prefix-checks in both
|
||||
directions. A hand-written `curl -X DELETE`, or the old
|
||||
`is_self "$id" || curl -X DELETE …`, has none of that: an undefined `is_self` exits
|
||||
127 and the `||` branch deletes unguarded.
|
||||
- If you created a *case* purely as scratch and the user confirmed it is disposable,
|
||||
`DELETE /api/v1/cases/:name` removes it — but that recursively deletes the
|
||||
directory from disk, so never do it without the user's explicit go-ahead for that
|
||||
exact name.
|
||||
+31
-3
@@ -135,6 +135,7 @@ export abstract class AiCheckerBase<
|
||||
// Active check state
|
||||
protected checkMuxName: string | null = null;
|
||||
protected checkTempFile: string | null = null;
|
||||
protected checkStderrFile: string | null = null;
|
||||
protected checkPromptFile: string | null = null;
|
||||
protected checkPollTimer: NodeJS.Timeout | null = null;
|
||||
protected checkTimeoutTimer: NodeJS.Timeout | null = null;
|
||||
@@ -376,6 +377,7 @@ export abstract class AiCheckerBase<
|
||||
const shortId = this.sessionId.slice(0, 8);
|
||||
const timestamp = Date.now();
|
||||
this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`);
|
||||
this.checkStderrFile = join(tmpdir(), `${this.tempFilePrefix}-stderr-${shortId}-${timestamp}.txt`);
|
||||
this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`);
|
||||
this.checkMuxName = `${this.muxNamePrefix}${shortId}`;
|
||||
|
||||
@@ -386,6 +388,7 @@ export abstract class AiCheckerBase<
|
||||
|
||||
// Ensure output temp file exists (empty) so we can poll it
|
||||
writeFileSync(this.checkTempFile, '');
|
||||
writeFileSync(this.checkStderrFile, '');
|
||||
|
||||
// Write prompt to file to avoid E2BIG error (argument list too long)
|
||||
// The prompt can be 16KB+ which exceeds shell argument limits
|
||||
@@ -396,7 +399,7 @@ export abstract class AiCheckerBase<
|
||||
const modelArg = `--model "${this.config.model.replace(/"/g, '\\"')}"`;
|
||||
const augmentedPath = getAugmentedPath();
|
||||
const claudeCmd = `cat "${this.checkPromptFile}" | claude -p ${modelArg} --output-format text`;
|
||||
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
|
||||
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2> "${this.checkStderrFile}"; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
|
||||
|
||||
// Spawn tmux session
|
||||
try {
|
||||
@@ -461,18 +464,32 @@ export abstract class AiCheckerBase<
|
||||
const output = content.replace(this.doneMarker, '').trim();
|
||||
|
||||
if (!output) {
|
||||
return this.createErrorResult(`Empty output from ${this.checkDescription}`, durationMs);
|
||||
const stderr = this.readStderrDiagnostic();
|
||||
const detail = stderr ? `: ${stderr}` : '';
|
||||
return this.createErrorResult(`Empty output from ${this.checkDescription}${detail}`, durationMs);
|
||||
}
|
||||
|
||||
// Delegate to subclass for verdict parsing
|
||||
const parsed = this.parseVerdict(output);
|
||||
if (!parsed) {
|
||||
return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs);
|
||||
const stderr = this.readStderrDiagnostic();
|
||||
const detail = stderr ? `; stderr: "${stderr}"` : '';
|
||||
return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"${detail}`, durationMs);
|
||||
}
|
||||
|
||||
return this.createResult(parsed.verdict, parsed.reasoning, durationMs);
|
||||
}
|
||||
|
||||
private readStderrDiagnostic(): string {
|
||||
if (!this.checkStderrFile || !existsSync(this.checkStderrFile)) return '';
|
||||
|
||||
try {
|
||||
return readFileSync(this.checkStderrFile, 'utf-8').trim().substring(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupCheck(): void {
|
||||
// Clear poll timer
|
||||
if (this.checkPollTimer) {
|
||||
@@ -509,6 +526,17 @@ export abstract class AiCheckerBase<
|
||||
this.checkTempFile = null;
|
||||
}
|
||||
|
||||
if (this.checkStderrFile) {
|
||||
try {
|
||||
if (existsSync(this.checkStderrFile)) {
|
||||
unlinkSync(this.checkStderrFile);
|
||||
}
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
this.checkStderrFile = null;
|
||||
}
|
||||
|
||||
if (this.checkPromptFile) {
|
||||
try {
|
||||
if (existsSync(this.checkPromptFile)) {
|
||||
|
||||
+468
-79
@@ -12,15 +12,20 @@ import chalk from 'chalk';
|
||||
import { createRequire } from 'module';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
import { getStore } from './state-store.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { version: string };
|
||||
@@ -116,6 +121,125 @@ program
|
||||
console.log(makeAttachmentMagicLink(filePath));
|
||||
});
|
||||
|
||||
// ============ Skill Commands ============
|
||||
|
||||
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
|
||||
/**
|
||||
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
|
||||
* shared single-user cases dir. Mirrors `resolveCasePath()` in `case-routes.ts`, which
|
||||
* is what the web UI and `quick-start` use. Without the linked-cases lookup this
|
||||
* command rejected every case linked in from outside `~/codeman-cases` with
|
||||
* "Case not found", even though the server resolved the same name fine.
|
||||
*
|
||||
* Sync and tolerant on purpose: a missing or malformed registry means "no linked
|
||||
* cases", never a crash.
|
||||
*/
|
||||
export function resolveCliCasePath(name: string): string {
|
||||
try {
|
||||
const linked = JSON.parse(readFileSync(LINKED_CASES_FILE, 'utf-8')) as Record<string, string>;
|
||||
const target = linked?.[name];
|
||||
if (typeof target === 'string' && target) return target;
|
||||
} catch {
|
||||
// no registry yet, or unreadable/invalid JSON: fall through to the cases dir
|
||||
}
|
||||
return join(homedir(), 'codeman-cases', name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where `skill install` / `skill uninstall` operate. Global is
|
||||
* `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new
|
||||
* session); `--case <name>` targets `<case>/.claude/skills/codeman`, resolved through
|
||||
* `resolveCliCasePath()` above. The web server's automatic per-case injection
|
||||
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
|
||||
* and stays single-user.
|
||||
*
|
||||
* A missing case is REPORTED, not exited on: the exit lives in the wrapper below so
|
||||
* this resolution (including the linked-cases lookup, which shipped unguarded) can be
|
||||
* unit-tested without `process.exit(1)` taking the test runner down with it.
|
||||
*/
|
||||
export function resolveSkillTargetPath(options: {
|
||||
case?: string;
|
||||
}): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } {
|
||||
if (options.case) {
|
||||
const casePath = resolveCliCasePath(options.case);
|
||||
if (!existsSync(casePath)) return { missingCase: casePath };
|
||||
return { target: join(casePath, '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
return { target: join(homedir(), '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
|
||||
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
const resolved = resolveSkillTargetPath(options);
|
||||
if (resolved.missingCase !== undefined) {
|
||||
console.error(chalk.red(`✗ Case not found: ${resolved.missingCase}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return resolved.target;
|
||||
}
|
||||
|
||||
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
|
||||
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
|
||||
const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = {
|
||||
installed: { ok: true, text: `Agent skill installed: ${target}` },
|
||||
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
|
||||
unchanged: { ok: true, text: `Agent skill already up to date: ${target}` },
|
||||
removed: { ok: true, text: `Agent skill removed: ${target}` },
|
||||
absent: { ok: true, text: `Nothing to remove at ${target}` },
|
||||
foreign: {
|
||||
ok: false,
|
||||
text: `${target} exists but is not Codeman-managed (no marker), refusing to touch it. Remove it yourself if you want the packaged skill there.`,
|
||||
},
|
||||
symlink: {
|
||||
ok: false,
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
const message = messages[result];
|
||||
if (message.ok) {
|
||||
console.log(chalk.green(`✓ ${message.text}`));
|
||||
} else {
|
||||
console.error(chalk.red(`✗ ${message.text}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const skillCmd = program
|
||||
.command('skill')
|
||||
.description('Manage the Codeman agent skill (lets an agent inside a session drive the API)');
|
||||
|
||||
skillCmd
|
||||
.command('install')
|
||||
.description('Install the agent skill globally (~/.claude/skills/codeman) or into one case')
|
||||
.option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)')
|
||||
.option('-c, --case <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
skillCmd
|
||||
.command('uninstall')
|
||||
.description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)')
|
||||
.option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)')
|
||||
.option('-c, --case <name>', 'Remove from <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
@@ -466,47 +590,152 @@ function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats'
|
||||
|
||||
// ============ Utility Commands ============
|
||||
|
||||
/** What probing the web server found. */
|
||||
interface WebServerProbe {
|
||||
reachable: boolean;
|
||||
/** The URL that answered, or the first candidate when nothing did. */
|
||||
url: string;
|
||||
statusCode?: number;
|
||||
version?: string;
|
||||
authRequired?: boolean;
|
||||
/** Live session states from `/api/status`, when the probe could read them. */
|
||||
sessions?: Array<{ status?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET `<base>/api/status` with a short timeout, tolerating the self-signed cert an
|
||||
* `--https` install uses. ANY HTTP answer proves the server is up: a 401 just
|
||||
* means it wants credentials (sent when available, same env → data-dir `.env`
|
||||
* fallback as `codeman attach`).
|
||||
*/
|
||||
function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL('/api/status', base);
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const envFile = readCodemanEnv();
|
||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (password) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'GET',
|
||||
path: url.pathname,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: 3000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (received <= 1024 * 1024) chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const statusCode = res.statusCode ?? 0;
|
||||
if (statusCode === 401) {
|
||||
resolve({ reachable: true, url: base, statusCode, authRequired: true });
|
||||
return;
|
||||
}
|
||||
let version: string | undefined;
|
||||
let sessions: Array<{ status?: string }> | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as {
|
||||
data?: { version?: unknown; sessions?: unknown };
|
||||
};
|
||||
const data = parsed?.data ?? (parsed as { version?: unknown; sessions?: unknown });
|
||||
if (typeof data?.version === 'string') version = data.version;
|
||||
if (Array.isArray(data?.sessions)) sessions = data.sessions as Array<{ status?: string }>;
|
||||
} catch {
|
||||
// Not JSON, but still an answer, so still running.
|
||||
}
|
||||
resolve({ reachable: true, url: base, statusCode, version, sessions });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
req.on('error', () => resolve(null));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show overall status')
|
||||
.action(() => {
|
||||
const manager = getSessionManager();
|
||||
const queue = getTaskQueue();
|
||||
const loop = getRalphLoop();
|
||||
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
const storedValues = Object.values(stored);
|
||||
const taskCounts = queue.getCount();
|
||||
const loopStatus = loop.status;
|
||||
|
||||
// Use live sessions if available, otherwise fall back to stored state
|
||||
const activeCount = sessions.length || storedValues.filter((s) => s.status !== 'stopped').length;
|
||||
const idleCount = sessions.length
|
||||
? sessions.filter((s) => s.isIdle()).length
|
||||
: storedValues.filter((s) => s.status === 'idle').length;
|
||||
const busyCount = sessions.length
|
||||
? sessions.filter((s) => s.isBusy()).length
|
||||
: storedValues.filter((s) => s.status === 'busy').length;
|
||||
.description('Show whether the Codeman web server is running, plus session/task state')
|
||||
.option('--url <url>', 'Server URL to probe (defaults to CODEMAN_API_URL, then local port)')
|
||||
.action(async (options: { url?: string }) => {
|
||||
// Issue #230: this command runs in its own fresh process, and the old output
|
||||
// reported THAT process's (always-stopped) Ralph loop under a bare "Status:",
|
||||
// reading as "the server is down" while the web service ran fine. Probe the
|
||||
// real server first; the Ralph loop has its own `codeman ralph status`.
|
||||
const port = process.env.CODEMAN_PORT || '3000';
|
||||
const candidates = options.url
|
||||
? [options.url]
|
||||
: process.env.CODEMAN_API_URL
|
||||
? [process.env.CODEMAN_API_URL]
|
||||
: [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
|
||||
let probe: WebServerProbe = { reachable: false, url: candidates[0] };
|
||||
for (const candidate of candidates) {
|
||||
const answer = await probeWebServerAt(candidate);
|
||||
if (answer) {
|
||||
probe = answer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nCodeman Status'));
|
||||
console.log('─'.repeat(40));
|
||||
|
||||
console.log(chalk.bold('\nSessions:'));
|
||||
console.log(` Active: ${activeCount}`);
|
||||
console.log(` Idle: ${idleCount}`);
|
||||
console.log(` Busy: ${busyCount}`);
|
||||
console.log(chalk.bold('\nWeb Server:'));
|
||||
if (probe.reachable) {
|
||||
const version = probe.version ? ` (v${probe.version})` : '';
|
||||
console.log(` Status: ${chalk.green('running')}${version} at ${probe.url}`);
|
||||
if (probe.authRequired) {
|
||||
console.log(chalk.gray(' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)'));
|
||||
}
|
||||
} else {
|
||||
console.log(` Status: ${chalk.red('not reachable')} at ${candidates.join(' or ')}`);
|
||||
console.log(
|
||||
chalk.gray(' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)')
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the server's live view; fall back to the shared saved state, labeled
|
||||
// as such, so the numbers are never silently a different thing.
|
||||
if (probe.sessions) {
|
||||
const live = probe.sessions;
|
||||
console.log(chalk.bold('\nSessions (live, from the server):'));
|
||||
console.log(` Total: ${live.length}`);
|
||||
console.log(` Idle: ${live.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${live.filter((s) => s.status === 'busy').length}`);
|
||||
} else {
|
||||
const manager = getSessionManager();
|
||||
const storedValues = Object.values(manager.getStoredSessions());
|
||||
console.log(chalk.bold('\nSessions (from saved state):'));
|
||||
console.log(` Active: ${storedValues.filter((s) => s.status !== 'stopped').length}`);
|
||||
console.log(` Idle: ${storedValues.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${storedValues.filter((s) => s.status === 'busy').length}`);
|
||||
}
|
||||
|
||||
const taskCounts = getTaskQueue().getCount();
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(` Total: ${taskCounts.total}`);
|
||||
console.log(` Pending: ${taskCounts.pending}`);
|
||||
console.log(` Running: ${taskCounts.running}`);
|
||||
console.log(` Completed: ${taskCounts.completed}`);
|
||||
console.log(` Failed: ${taskCounts.failed}`);
|
||||
|
||||
const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
|
||||
console.log(chalk.bold('\nRalph Loop:'));
|
||||
console.log(` Status: ${statusColor(loopStatus)}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
@@ -572,64 +801,224 @@ program
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// ============ Web / daemon / service Commands ============
|
||||
|
||||
/** Shared option set for the commands that can launch a web server. */
|
||||
function addWebLaunchOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option(
|
||||
'--multiuser',
|
||||
'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)'
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalize commander's strings into the shape daemon-control/service-installer take. */
|
||||
function toWebLaunchOptions(options: {
|
||||
host: string;
|
||||
port: string;
|
||||
https?: boolean;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
}): WebLaunchOptions {
|
||||
const port = parseInt(options.port, 10);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
console.error(chalk.red(`✗ Invalid port: ${options.port}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
host: options.host,
|
||||
port,
|
||||
https: !!options.https,
|
||||
titleHostname: options.titleHostname,
|
||||
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
|
||||
multiuser: !!options.multiuser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The server prints this itself, but into a log file nobody reads when it is
|
||||
* detached or supervised. Repeat it where the operator is actually looking.
|
||||
*/
|
||||
function warnIfUnauthenticatedNetwork(launch: WebLaunchOptions): void {
|
||||
if (isLoopbackBindHost(launch.host)) return;
|
||||
if (isUnauthenticatedNetworkAcknowledged(launch.allowUnauthenticatedNetwork)) return;
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠ Binding ${launch.host} without CODEMAN_PASSWORD: anyone who can reach this port gets terminal control.`
|
||||
)
|
||||
);
|
||||
console.log(chalk.yellow(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.'));
|
||||
}
|
||||
|
||||
// Web interface command
|
||||
program
|
||||
.command('web')
|
||||
.description('Start the web interface')
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)')
|
||||
.action(async (options) => {
|
||||
// The flag is surfaced to the rest of the process via the env var so
|
||||
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
|
||||
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
const webCmd = addWebLaunchOptions(program.command('web').description('Start the web interface'))
|
||||
.option('-d, --daemon', 'Run detached in the background; survives the shell, logs to <data dir>/web.log')
|
||||
.option('--stop', 'Stop a server started with --daemon')
|
||||
.option('--status', 'Report whether a detached server is running');
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
webCmd.action(async (options) => {
|
||||
// The flag is surfaced to the rest of the process via the env var so
|
||||
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
|
||||
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
|
||||
const launch = toWebLaunchOptions(options);
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
if (options.stop) {
|
||||
const result = await stopDaemon(launch);
|
||||
if (result.ok && result.reason === 'not-running') {
|
||||
console.log(chalk.gray(`○ ${result.message}`));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`));
|
||||
console.log(chalk.gray(' Your agents keep running in tmux.'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`✗ ${result.message ?? 'Could not stop the server'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.status) {
|
||||
const status = await daemonStatus(launch);
|
||||
if (status.responding) {
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(chalk.green(`✓ Responding at ${status.url}${version}`));
|
||||
} else {
|
||||
console.log(chalk.yellow(`○ Nothing answering at ${status.url}`));
|
||||
}
|
||||
console.log(` Daemon pid: ${status.running ? chalk.green(String(status.pid)) : chalk.gray('not running')}`);
|
||||
console.log(chalk.gray(` Pidfile: ${status.pidFile}`));
|
||||
console.log(chalk.gray(` Log: ${status.logPath}`));
|
||||
if (!status.running && status.responding) {
|
||||
console.log(chalk.gray(' (running, but not started with --daemon: probably a service or a foreground run)'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.daemon) {
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Starting Codeman in the background...'));
|
||||
const result = await startDaemon(launch);
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`));
|
||||
console.log(chalk.gray(` Logs: ${result.logPath}`));
|
||||
console.log(chalk.gray(' Stop it with: codeman web --stop'));
|
||||
console.log(chalk.gray(' Want it back after a reboot? codeman service install'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`\n✗ ${result.message ?? 'Failed to start'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = launch.host;
|
||||
const port = launch.port;
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||
|
||||
// Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`));
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`));
|
||||
}
|
||||
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`));
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`));
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`));
|
||||
// Supervised service: the "still there after a reboot" answer, where `web -d` is
|
||||
// the "still there after I close this shell" one (issue #231).
|
||||
const serviceCmd = program
|
||||
.command('service')
|
||||
.description('Manage the background service (systemd user unit on Linux, LaunchAgent on macOS)');
|
||||
|
||||
addWebLaunchOptions(
|
||||
serviceCmd.command('install').description('Install and start the service, then verify it answers')
|
||||
).action(async (options) => {
|
||||
const launch = toWebLaunchOptions(options);
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Installing the Codeman service...'));
|
||||
|
||||
const result = await installService(launch);
|
||||
for (const warning of result.warnings ?? []) console.log(chalk.yellow(`⚠ ${warning}`));
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
console.log(chalk.gray(` Unit: ${result.unitPath}`));
|
||||
if (process.env.CODEMAN_PASSWORD) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Note: CODEMAN_PASSWORD was NOT copied into the unit file. Add it there yourself if the service needs auth.'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
serviceCmd
|
||||
.command('uninstall')
|
||||
.description('Stop the service and remove its unit file')
|
||||
.action(() => {
|
||||
const result = uninstallService();
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
});
|
||||
|
||||
addWebLaunchOptions(
|
||||
serviceCmd.command('status').description('Show whether the service is installed and running')
|
||||
).action(async (options) => {
|
||||
const status = await serviceStatus(toWebLaunchOptions(options));
|
||||
if (!status.kind) {
|
||||
console.log(chalk.yellow(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`));
|
||||
return;
|
||||
}
|
||||
console.log(` Supervisor: ${status.kind} (${status.name})`);
|
||||
console.log(` Unit file: ${status.installed ? chalk.green(status.unitPath) : chalk.gray('not installed')}`);
|
||||
console.log(` Loaded: ${status.loaded ? chalk.green('yes') : chalk.gray('no')}`);
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(
|
||||
` Responding: ${status.responding ? chalk.green(`yes at ${status.url}${version}`) : chalk.gray(`no at ${status.url}`)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ============ Multi-user Commands ============
|
||||
//
|
||||
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @fileoverview Bounds for the agent wait primitives.
|
||||
*
|
||||
* These back the blocking endpoints an agent uses to orchestrate other sessions
|
||||
* (`GET /api/sessions/:id/wait`, `GET /api/sessions/:id/wait-output`, and the
|
||||
* `wait` field on `POST /api/sessions/:id/input`). Plan: `docs/agent-control-plan.md`.
|
||||
*
|
||||
* Why every value is bounded:
|
||||
* - An unbounded long-poll is a socket leak. A caller that asks for a 12-hour wait
|
||||
* and walks away holds a connection (and a waiter, and a timer) until the process
|
||||
* restarts, so `MAX_WAIT_MS` is a hard ceiling applied server-side.
|
||||
* - `DEFAULT_WAIT_MS` is deliberately short (60s). Production is reached through
|
||||
* `tailscale serve` and users also run cloudflared tunnels; both can cut an idle
|
||||
* connection, so the documented pattern is a client-side loop over short waits
|
||||
* rather than one very long call. Fastify itself is happy to hold the request
|
||||
* (`requestTimeout` defaults to 0, and `keepAliveTimeout` applies between
|
||||
* requests, not to an in-flight one), the intermediaries are the constraint.
|
||||
* - The waiter caps mirror `MAX_SSE_CLIENTS` in `map-limits.ts`: each pending
|
||||
* waiter costs an open HTTP response plus a timer, so the pool is capped rather
|
||||
* than queued. Exceeding a cap is an explicit error, never a silent wait.
|
||||
* - There are THREE caps, not two, because a process-wide pool with no per-user
|
||||
* dimension lets one user deny the primitive to everyone else. `middleware/auth.ts`
|
||||
* already treats that shape as a bug (its `userFailures` bucket exists so "one user
|
||||
* behind a NAT can't lock out everyone else"); `MAX_WAITERS_PER_OWNER` is the same
|
||||
* idea for waiters. It applies only when the caller has an owner, so single-user
|
||||
* mode is byte-identical to having no owner cap at all.
|
||||
*
|
||||
* All values are env-overridable and clamped to sane hard bounds, so a typo in an
|
||||
* env var degrades to the default instead of disabling the protection.
|
||||
*
|
||||
* @module config/agent-wait
|
||||
*/
|
||||
|
||||
/** Absolute floor for any wait, in ms. Sub-second waits are polling, not waiting. */
|
||||
export const MIN_WAIT_MS = 1_000;
|
||||
|
||||
/** Ceiling the operator-configurable maximum is itself clamped to. */
|
||||
const HARD_MAX_WAIT_MS = 3_600_000;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** Longest a single wait may block. Requests above this are clamped down, not rejected. */
|
||||
export const MAX_WAIT_MS = envInt('CODEMAN_WAIT_MAX_MS', 600_000, MIN_WAIT_MS, HARD_MAX_WAIT_MS);
|
||||
|
||||
/** Used when the caller omits `timeout`. Never exceeds MAX_WAIT_MS. */
|
||||
export const DEFAULT_WAIT_MS = Math.min(
|
||||
envInt('CODEMAN_WAIT_DEFAULT_MS', 60_000, MIN_WAIT_MS, HARD_MAX_WAIT_MS),
|
||||
MAX_WAIT_MS
|
||||
);
|
||||
|
||||
/** Concurrent waiters (signal + output) allowed against one session. */
|
||||
export const MAX_WAITERS_PER_SESSION = envInt('CODEMAN_WAIT_MAX_PER_SESSION', 16, 1, 256);
|
||||
|
||||
/**
|
||||
* Ceiling the operator-configurable total is itself clamped to.
|
||||
*
|
||||
* 512 rather than the 4096 this started at. Every other knob in this file degrades
|
||||
* safely on a bad value; a 4096 ceiling instead lets a well-meaning operator turn the
|
||||
* protection into the problem, since 4096 concurrent held responses (each an open
|
||||
* socket, a timer and a pending promise) exceeds the 1024 soft `RLIMIT_NOFILE` that is
|
||||
* still the default on most Linux distros, before counting PTYs, SSE clients and
|
||||
* WebSockets. 512 is ~5x `MAX_SSE_CLIENTS` (100, the pool this one is modelled on), so
|
||||
* the knob stays useful for a busy orchestration host while the whole server still fits
|
||||
* inside a default fd budget with room to spare.
|
||||
*/
|
||||
const HARD_MAX_WAITERS_TOTAL = 512;
|
||||
|
||||
/** Concurrent waiters allowed across every session in the process. */
|
||||
export const MAX_WAITERS_TOTAL = envInt('CODEMAN_WAIT_MAX_TOTAL', 128, 1, HARD_MAX_WAITERS_TOTAL);
|
||||
|
||||
/**
|
||||
* Concurrent waiters allowed for one owner (multi-user mode's `Session.owner`).
|
||||
*
|
||||
* Sits between the per-session cap (16) and the process-wide one (128): high enough
|
||||
* that one user orchestrating several workers at once never trips it, low enough that
|
||||
* a single user cannot occupy the whole pool and deny the primitive to everyone else,
|
||||
* admin included. Ignored entirely when the caller has no owner, which is every
|
||||
* request in single-user mode.
|
||||
*/
|
||||
export const MAX_WAITERS_PER_OWNER = envInt('CODEMAN_WAIT_MAX_PER_OWNER', 48, 1, HARD_MAX_WAITERS_TOTAL);
|
||||
|
||||
/** Bounds on the literal `match` string accepted by wait-output. */
|
||||
export const MIN_MATCH_LENGTH = 1;
|
||||
export const MAX_MATCH_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Tail of the terminal buffer scanned by `wait-output?from=buffer`.
|
||||
*
|
||||
* The buffer itself runs to 32MB. Scanning all of it would be an ANSI strip over
|
||||
* 32MB (a full second copy) on a request an agent may issue in a loop, and the
|
||||
* question `from=buffer` answers is "did this appear recently", not "ever". The
|
||||
* tail is continuous with the live stream, since `_terminalBuffer.append(data)`
|
||||
* and `emit('terminal', data)` receive the same bytes.
|
||||
*/
|
||||
export const MAX_BUFFER_SCAN_BYTES = envInt('CODEMAN_WAIT_BUFFER_SCAN_BYTES', 256 * 1024, 4 * 1024, 8 * 1024 * 1024);
|
||||
|
||||
/** Characters of surrounding output returned either side of a wait-output match. */
|
||||
export const MAX_SNIPPET_CONTEXT = 80;
|
||||
|
||||
/**
|
||||
* Clamp a caller-supplied timeout into [MIN_WAIT_MS, MAX_WAIT_MS].
|
||||
* Absent / non-numeric / non-finite input falls back to DEFAULT_WAIT_MS.
|
||||
*/
|
||||
export function clampWaitMs(value: unknown): number {
|
||||
const n = typeof value === 'string' ? Number(value) : value;
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return DEFAULT_WAIT_MS;
|
||||
return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, Math.trunc(n)));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Supervisor identity (systemd unit name / launchd job label).
|
||||
*
|
||||
* Three things now write or look for the same supervisor job: `install.sh`, the
|
||||
* in-app self-updater (`web/self-update.ts` detects it to decide how to restart),
|
||||
* and `codeman service install`. The names live here so they cannot drift apart,
|
||||
* because a mismatch is silent in the worst way: `service install` would happily
|
||||
* create a SECOND job alongside the installer's, and two servers sharing one data
|
||||
* dir and one tmux socket attach PTYs to each other's live sessions
|
||||
* (see config/instance.ts).
|
||||
*
|
||||
* The names are instance-scoped for exactly that reason: a `CODEMAN_INSTANCE=beta`
|
||||
* build writing `com.codeman.web` would overwrite the production LaunchAgent. The
|
||||
* DEFAULT instance keeps the historical names byte-identical, so existing installs
|
||||
* and every unit install.sh has already written are unaffected.
|
||||
*
|
||||
* @module config/service-names
|
||||
*/
|
||||
|
||||
import { CODEMAN_INSTANCE } from './instance.js';
|
||||
|
||||
/**
|
||||
* Instance name reduced to characters that are safe in a filename and in a
|
||||
* launchd label. `CODEMAN_INSTANCE` is arbitrary operator input.
|
||||
*/
|
||||
const SAFE_INSTANCE = CODEMAN_INSTANCE.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32);
|
||||
|
||||
/** systemd user unit: `codeman-web.service`, or `codeman-web-beta.service` for a beta. */
|
||||
export const SYSTEMD_UNIT = `codeman-web${SAFE_INSTANCE ? `-${SAFE_INSTANCE}` : ''}.service`;
|
||||
|
||||
/** launchd job label: `com.codeman.web`, or `com.codeman.beta.web` for a beta. */
|
||||
export const LAUNCHD_LABEL = SAFE_INSTANCE ? `com.codeman.${SAFE_INSTANCE}.web` : 'com.codeman.web';
|
||||
@@ -29,12 +29,29 @@ export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_
|
||||
/** Max concurrent capabilities held in memory before the oldest are dropped. */
|
||||
export const MAX_WEBVIEW_CAPABILITIES = 200;
|
||||
|
||||
/** Upstream request timeout for a proxied HTTP request. */
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
|
||||
/**
|
||||
* How long a proxied HTTP request waits for the upstream's RESPONSE HEADERS.
|
||||
*
|
||||
* This bounds time-to-headers only, never an actively streaming body: the proxy
|
||||
* clears the timer the moment headers arrive (issue #237: the old 30s
|
||||
* `AbortSignal.timeout` bounded the whole fetch and killed slow AI/model endpoints
|
||||
* and long streams alike, as a silent 502). 300s because "the app is thinking" is
|
||||
* normal for the dashboards people proxy; abandoned upstreams are reclaimed by the
|
||||
* client-hangup abort, not by this value, so a generous default costs nothing.
|
||||
*/
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 300_000);
|
||||
|
||||
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
|
||||
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
|
||||
|
||||
/**
|
||||
* WebSocket upgrade handshake timeout. Deliberately decoupled from
|
||||
* WEBVIEW_UPSTREAM_TIMEOUT_MS: a handshake is connection establishment, and waiting
|
||||
* minutes on one only delays the browser's reconnect logic. Matches the pre-#237
|
||||
* behavior (the handshake used to ride the 30s upstream timeout).
|
||||
*/
|
||||
export const WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS', 30_000);
|
||||
|
||||
/**
|
||||
* Max bytes of an HTML response buffered for `<base>` injection and link
|
||||
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* @fileoverview Detached `codeman web` control: start (-d), stop, status.
|
||||
*
|
||||
* Backs `codeman web -d`, `codeman web --stop` and `codeman web --status`. The
|
||||
* server itself is unchanged; this module re-launches the SAME entry script in a
|
||||
* new session (`detached: true` calls setsid), so the child has no controlling
|
||||
* terminal and no shell job entry. That is what actually makes it outlive the
|
||||
* shell: `nohup` does not, because Node re-arms SIGHUP to its default disposition
|
||||
* even when it inherits "ignore", and `cli.ts` installs a SIGHUP handler that
|
||||
* shuts the server down gracefully (issue #231).
|
||||
*
|
||||
* Two rules shape the rest of the module:
|
||||
*
|
||||
* 1. **Never start a second server on one data dir.** `~/.codeman` and the
|
||||
* `tmux -L codeman` socket are process-wide (config/instance.ts), so a second
|
||||
* instance discovers and attaches PTYs to the first one's live sessions and
|
||||
* starts resizing them. A double `-d` therefore has to be a hard error, which
|
||||
* means checking both the pidfile AND the port before spawning.
|
||||
* 2. **Never report success we have not seen.** The parent polls `/api/status`
|
||||
* until the child answers (or dies) before printing a URL. A port clash or a
|
||||
* missing dependency otherwise looks exactly like a clean start.
|
||||
*
|
||||
* Pure helpers (arg building, URL building, pidfile parsing, the process-identity
|
||||
* check) are exported separately so they can be unit-tested without spawning.
|
||||
*
|
||||
* @module daemon-control
|
||||
*/
|
||||
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
|
||||
/** How long to wait for a freshly spawned server to answer `/api/status`. */
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
/** How long to wait for a SIGTERM'd server to actually exit before giving up. */
|
||||
const STOP_TIMEOUT_MS = 15_000;
|
||||
/** Poll interval while waiting for either of the above. */
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
/** The `web` command's options, as far as a detached relaunch cares about them. */
|
||||
export interface WebLaunchOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
https: boolean;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
}
|
||||
|
||||
export interface StartResult {
|
||||
ok: boolean;
|
||||
pid?: number;
|
||||
url?: string;
|
||||
/** Machine-readable failure cause; `undefined` on success. */
|
||||
reason?: 'already-running' | 'exited' | 'timeout';
|
||||
message?: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface StopResult {
|
||||
ok: boolean;
|
||||
pid?: number;
|
||||
reason?: 'not-running' | 'foreign-pid' | 'timeout' | 'no-pidfile-but-responding';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DaemonStatus {
|
||||
pid: number | null;
|
||||
/** The pid in the pidfile is alive AND still looks like a Codeman web process. */
|
||||
running: boolean;
|
||||
/** Something answered `/api/status` at the expected address. */
|
||||
responding: boolean;
|
||||
version?: string;
|
||||
url: string;
|
||||
pidFile: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Rebuild the `web` argv for the child, dropping the daemon flags themselves. */
|
||||
export function buildWebArgs(options: WebLaunchOptions): string[] {
|
||||
const args = ['web', '--host', options.host, '--port', String(options.port)];
|
||||
if (options.https) args.push('--https');
|
||||
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
|
||||
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
|
||||
if (options.multiuser) args.push('--multiuser');
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectable address for this bind. A wildcard bind is not itself connectable,
|
||||
* so `0.0.0.0` / `::` become loopback; a bare IPv6 literal gets bracketed.
|
||||
*/
|
||||
export function buildBaseUrl(options: WebLaunchOptions): string {
|
||||
const protocol = options.https ? 'https' : 'http';
|
||||
let host = options.host.trim();
|
||||
if (host === '0.0.0.0' || host === '::' || host === '') host = '127.0.0.1';
|
||||
if (host.includes(':') && !host.startsWith('[')) host = `[${host}]`;
|
||||
return `${protocol}://${host}:${options.port}`;
|
||||
}
|
||||
|
||||
/** The endpoint polled for readiness. */
|
||||
export function buildStatusUrl(options: WebLaunchOptions): string {
|
||||
return `${buildBaseUrl(options)}/api/status`;
|
||||
}
|
||||
|
||||
/** Parse a pidfile body. Rejects garbage, and pid 1 (init is never ours). */
|
||||
export function parsePidFileContents(text: string): number | null {
|
||||
const trimmed = text.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const pid = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isSafeInteger(pid) || pid <= 1) return null;
|
||||
return pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this command line look like a Codeman web server?
|
||||
*
|
||||
* Pids are recycled, and a stale pidfile pointing at whatever inherited the
|
||||
* number is a live footgun: `codeman web --stop` must not SIGTERM an unrelated
|
||||
* process. Both the npm bin (`codeman`/`aicodeman`) and the direct entry
|
||||
* (`node dist/index.js web`, `tsx src/index.ts web`) have to match.
|
||||
*/
|
||||
export function looksLikeCodemanWeb(command: string | null | undefined): boolean {
|
||||
if (!command) return false;
|
||||
if (!/(^|\s)web(\s|$)/.test(command)) return false;
|
||||
return /(^|[/\s])(ai)?codeman(\s|$)/.test(command) || /index\.(js|ts)(\s|$)/.test(command);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Paths
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved at call time, not module load: tests swap `HOME` per file, and the
|
||||
* data dir is derived from it (see test/setup.ts).
|
||||
*/
|
||||
export function pidFilePath(): string {
|
||||
return dataPath('web.pid');
|
||||
}
|
||||
|
||||
/** Where a detached server's stdout/stderr is appended. */
|
||||
export function logFilePath(): string {
|
||||
return dataPath('web.log');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Process probing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Signal 0 liveness check. EPERM means the pid exists but is not ours. */
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
/** Full command line of a pid, or null. `-o command=` is portable to macOS. */
|
||||
export function readProcessCommand(pid: number): string | null {
|
||||
try {
|
||||
const out = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return out.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the pidfile, returning null when it is missing, empty or malformed. */
|
||||
export function readPidFile(): number | null {
|
||||
const file = pidFilePath();
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
return parsePidFileContents(readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile(): void {
|
||||
try {
|
||||
unlinkSync(pidFilePath());
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Pid of a live Codeman web server recorded in the pidfile, or null. */
|
||||
export function readLivePid(): number | null {
|
||||
const pid = readPidFile();
|
||||
if (pid === null) return null;
|
||||
if (!isProcessAlive(pid)) return null;
|
||||
// A recycled pid is not ours. `ps` can also legitimately fail (containers with
|
||||
// no procps); treat "cannot tell" as ours rather than orphaning the pidfile.
|
||||
const command = readProcessCommand(pid);
|
||||
if (command !== null && !looksLikeCodemanWeb(command)) return null;
|
||||
return pid;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP readiness probe
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProbeResult {
|
||||
/** A Codeman server answered. A 401 counts: auth is active, the server is up. */
|
||||
up: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe `/api/status`. Self-signed certs are accepted (`--https` generates one),
|
||||
* and 401 counts as up because `CODEMAN_PASSWORD` gates that route. The body is
|
||||
* checked so an unrelated service squatting on the port is not read as success.
|
||||
*/
|
||||
export function probeServer(url: string, timeoutMs = 2000): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (result: ProbeResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url);
|
||||
} catch {
|
||||
done({ up: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = target.protocol === 'https:' ? https : http;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: target.pathname,
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false,
|
||||
timeout: timeoutMs,
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode === 401) {
|
||||
res.resume();
|
||||
done({ up: true });
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
res.setEncoding('utf-8');
|
||||
res.on('data', (chunk: string) => {
|
||||
if (body.length < 4096) body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (!body.includes('"success"')) {
|
||||
done({ up: false });
|
||||
return;
|
||||
}
|
||||
let version: string | undefined;
|
||||
try {
|
||||
version = (JSON.parse(body) as { data?: { version?: string } }).data?.version;
|
||||
} catch {
|
||||
/* body was truncated at 4KB; up is still true */
|
||||
}
|
||||
done({ up: true, version });
|
||||
});
|
||||
res.on('error', () => done({ up: false }));
|
||||
}
|
||||
);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
done({ up: false });
|
||||
});
|
||||
req.on('error', () => done({ up: false }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Start / stop / status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The script to relaunch. `process.execArgv` is carried over with it so a dev
|
||||
* run under tsx (whose execArgv holds the tsx loader flags) re-launches through
|
||||
* tsx instead of handing a `.ts` file to bare node.
|
||||
*/
|
||||
function entryScript(): string {
|
||||
const script = process.argv[1];
|
||||
if (!script) throw new Error('cannot determine the codeman entry script to relaunch');
|
||||
return script;
|
||||
}
|
||||
|
||||
/** Marks one launch in the append-only log so a tail cannot mix two runs. */
|
||||
const LOG_SEPARATOR = '=== codeman web start';
|
||||
|
||||
/**
|
||||
* Last few lines of the daemon log, for reporting a failed start. The log is
|
||||
* append-only across launches, so the tail starts at the last separator when
|
||||
* there is one: otherwise a crash report is padded with the previous run's
|
||||
* cheerful startup banner.
|
||||
*/
|
||||
export function tailLog(maxLines = 15): string {
|
||||
try {
|
||||
const lines = readFileSync(logFilePath(), 'utf-8').trimEnd().split('\n');
|
||||
const start = lines.map((line) => line.startsWith(LOG_SEPARATOR)).lastIndexOf(true);
|
||||
const current = start === -1 ? lines : lines.slice(start + 1);
|
||||
return current.slice(-maxLines).join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached `codeman web` and wait until it answers before returning.
|
||||
* Refuses when a server is already up on this data dir (see rule 1 in the module
|
||||
* docblock).
|
||||
*/
|
||||
export async function startDaemon(options: WebLaunchOptions): Promise<StartResult> {
|
||||
const logPath = logFilePath();
|
||||
const url = buildBaseUrl(options);
|
||||
const statusUrl = buildStatusUrl(options);
|
||||
|
||||
const existingPid = readLivePid();
|
||||
if (existingPid !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'already-running',
|
||||
pid: existingPid,
|
||||
logPath,
|
||||
message: `a Codeman server is already running (pid ${existingPid}). Stop it with \`codeman web --stop\` first.`,
|
||||
};
|
||||
}
|
||||
const alreadyServing = await probeServer(statusUrl, 1500);
|
||||
if (alreadyServing.up) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'already-running',
|
||||
logPath,
|
||||
url,
|
||||
message: `something is already serving ${url}. Two servers on one data dir attach to each other's tmux sessions, so refusing to start.`,
|
||||
};
|
||||
}
|
||||
// A pidfile that survived a crash: the process is gone, so it is just litter.
|
||||
if (readPidFile() !== null) removePidFile();
|
||||
|
||||
const args = buildWebArgs(options);
|
||||
try {
|
||||
appendFileSync(logPath, `\n${LOG_SEPARATOR} ${new Date().toISOString()} ===\n`, 'utf-8');
|
||||
} catch {
|
||||
/* the spawn below reports a genuinely unwritable log */
|
||||
}
|
||||
const logFd = openSync(logPath, 'a');
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [...process.execArgv, entryScript(), ...args], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env: process.env,
|
||||
});
|
||||
} finally {
|
||||
closeSync(logFd);
|
||||
}
|
||||
|
||||
let exited = false;
|
||||
child.on('exit', () => {
|
||||
exited = true;
|
||||
});
|
||||
child.on('error', () => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) {
|
||||
return { ok: false, reason: 'exited', logPath, message: 'failed to spawn the server process' };
|
||||
}
|
||||
writeFileSync(pidFilePath(), `${pid}\n`, 'utf-8');
|
||||
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) {
|
||||
removePidFile();
|
||||
child.unref();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'exited',
|
||||
logPath,
|
||||
message: `the server exited during startup. Last lines of ${logPath}:\n${tailLog()}`,
|
||||
};
|
||||
}
|
||||
const probe = await probeServer(statusUrl, 1000);
|
||||
if (probe.up) {
|
||||
child.unref();
|
||||
return { ok: true, pid, url, logPath };
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
child.unref();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'timeout',
|
||||
pid,
|
||||
url,
|
||||
logPath,
|
||||
message: `the server did not answer ${url} within ${START_TIMEOUT_MS / 1000}s. It may still be starting; check ${logPath}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** SIGTERM the recorded server and wait for it to actually exit. */
|
||||
export async function stopDaemon(options: WebLaunchOptions): Promise<StopResult> {
|
||||
const pid = readPidFile();
|
||||
if (pid === null) {
|
||||
const probe = await probeServer(buildStatusUrl(options), 1500);
|
||||
if (probe.up) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-pidfile-but-responding',
|
||||
message:
|
||||
'a server is responding but there is no pidfile, so it was not started with `-d`. If it is a service use `codeman service uninstall` (or stop the unit); otherwise `pkill -f "index.js web"`.',
|
||||
};
|
||||
}
|
||||
return { ok: true, reason: 'not-running', message: 'no daemon is running; nothing to stop' };
|
||||
}
|
||||
|
||||
if (!isProcessAlive(pid)) {
|
||||
removePidFile();
|
||||
return { ok: true, pid, message: `stale pidfile removed (pid ${pid} was not running)` };
|
||||
}
|
||||
|
||||
const command = readProcessCommand(pid);
|
||||
if (command !== null && !looksLikeCodemanWeb(command)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'foreign-pid',
|
||||
pid,
|
||||
message: `pid ${pid} is not a Codeman server (${command}). Refusing to signal it; delete ${pidFilePath()} if it is stale.`,
|
||||
};
|
||||
}
|
||||
|
||||
// SIGTERM, never SIGKILL: cli.ts flushes state on the way out.
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'foreign-pid', pid, message: `could not signal pid ${pid}: ${String(err)}` };
|
||||
}
|
||||
|
||||
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
removePidFile();
|
||||
return { ok: true, pid };
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'timeout',
|
||||
pid,
|
||||
message: `pid ${pid} did not exit within ${STOP_TIMEOUT_MS / 1000}s. Force it with \`kill -9 ${pid}\` if you are sure.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Report on both halves: the recorded process, and whether the port answers. */
|
||||
export async function daemonStatus(options: WebLaunchOptions): Promise<DaemonStatus> {
|
||||
const url = buildBaseUrl(options);
|
||||
const pid = readPidFile();
|
||||
const probe = await probeServer(buildStatusUrl(options), 2000);
|
||||
return {
|
||||
pid,
|
||||
running: readLivePid() !== null,
|
||||
responding: probe.up,
|
||||
version: probe.version,
|
||||
url,
|
||||
pidFile: pidFilePath(),
|
||||
logPath: logFilePath(),
|
||||
};
|
||||
}
|
||||
+439
-30
@@ -10,13 +10,16 @@
|
||||
* Key exports:
|
||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
||||
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
|
||||
* - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case
|
||||
* (no production call site yet; see its doc comment before wiring one)
|
||||
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
||||
*
|
||||
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
||||
* `stop`, `teammate_idle`, `task_completed`
|
||||
*
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1),
|
||||
* `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake)
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1),
|
||||
* `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained
|
||||
* background Bash rewake)
|
||||
*
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS)
|
||||
* @consumedby web/server (session creation), session-cli-builder (env setup)
|
||||
@@ -24,9 +27,11 @@
|
||||
* @module hooks-config
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { readFile, writeFile, mkdir, lstat, readdir, rename, unlink, rmdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
@@ -38,6 +43,9 @@ import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
* while an App-Settings toggle injects the statusLine into the same repo — can't
|
||||
* lose each other's changes through interleaved read-then-write. Per-path chains
|
||||
* are independent; the map self-prunes when a path's chain goes idle.
|
||||
*
|
||||
* The agent-skill injector keys the same map on its skill DIRECTORY, which can never
|
||||
* collide with a settings-file path, so those writers serialize against each other too.
|
||||
*/
|
||||
const settingsWriteLocks = new Map<string, Promise<unknown>>();
|
||||
/**
|
||||
@@ -52,15 +60,19 @@ const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V';
|
||||
* changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as
|
||||
* stale, so healed cases pick up the new script on next launch.
|
||||
*/
|
||||
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}2`;
|
||||
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}3`;
|
||||
const SUBAGENT_STOP_GUARD_MARKER_PREFIX = 'CODEMAN_SUBAGENT_STOP_GUARD_V';
|
||||
const SUBAGENT_STOP_GUARD_MARKER = `${SUBAGENT_STOP_GUARD_MARKER_PREFIX}1`;
|
||||
const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Inline Node helper for Claude Code's `asyncRewake` hook.
|
||||
*
|
||||
* A background Bash tool returns immediately with a task ID, then Claude writes
|
||||
* its completion as a queue-operation in the transcript. Watching that durable
|
||||
* record avoids injecting terminal input (which could submit a user's draft).
|
||||
* its completion as a queue-operation in the top-level transcript. Subagent hooks
|
||||
* receive their own transcript path even though their completion is parent-owned,
|
||||
* so the helper watches both paths. Watching durable records avoids injecting
|
||||
* terminal input (which could submit a user's draft).
|
||||
* The helper is embedded in settings via `node -e`, so it has no script path
|
||||
* that can go stale after an install or plugin-cache cleanup.
|
||||
*
|
||||
@@ -72,8 +84,12 @@ const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
|
||||
export function generateBackgroundWakeScript(): string {
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
"const path = require('node:path');",
|
||||
`const ${BACKGROUND_WAKE_MARKER} = true;`,
|
||||
`const deadline = Date.now() + ${BACKGROUND_WAKE_TIMEOUT_SECONDS} * 1000;`,
|
||||
"const RESULT_BEGIN = '=== CODEMAN_RESULT_BEGIN ===';",
|
||||
"const RESULT_END = '=== CODEMAN_RESULT_END ===';",
|
||||
'const MAX_RESULT_CHARS = 65536;',
|
||||
'let input = {};',
|
||||
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
|
||||
'function findTaskId(value) {',
|
||||
@@ -98,46 +114,164 @@ export function generateBackgroundWakeScript(): string {
|
||||
'const taskId = findTaskId(input.tool_response);',
|
||||
"const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';",
|
||||
'if (!taskId || !transcriptPath) process.exit(0);',
|
||||
'let position = 0;',
|
||||
'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }',
|
||||
"let carry = '';",
|
||||
'const transcriptPaths = [transcriptPath];',
|
||||
'const sessionDir = path.dirname(path.dirname(transcriptPath));',
|
||||
"if (typeof input.agent_id === 'string' && path.basename(path.dirname(transcriptPath)) === 'subagents' &&",
|
||||
" typeof input.session_id === 'string' && path.basename(sessionDir) === input.session_id) {",
|
||||
" transcriptPaths.push(sessionDir + '.jsonl');",
|
||||
'}',
|
||||
'const transcripts = [...new Set(transcriptPaths)].map((transcript) => {',
|
||||
' let position = 0;',
|
||||
' try { position = Math.max(0, fs.statSync(transcript).size - 262144); } catch {}',
|
||||
" return { path: transcript, position, carry: '' };",
|
||||
'});',
|
||||
'if (!transcripts.some((transcript) => fs.existsSync(transcript.path))) process.exit(0);',
|
||||
'function readMarkedResult(outputPath) {',
|
||||
" if (!outputPath || !path.isAbsolute(outputPath) || path.basename(outputPath) !== taskId + '.output') return '';",
|
||||
" if (path.basename(path.dirname(outputPath)) !== 'tasks') return '';",
|
||||
' try {',
|
||||
' const size = fs.statSync(outputPath).size;',
|
||||
' const length = Math.min(size, MAX_RESULT_CHARS * 2);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(outputPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, size - length);',
|
||||
' fs.closeSync(fd);',
|
||||
" const text = buffer.subarray(0, bytes).toString('utf8');",
|
||||
' const begin = text.lastIndexOf(RESULT_BEGIN);',
|
||||
' const end = text.indexOf(RESULT_END, begin + RESULT_BEGIN.length);',
|
||||
" if (begin < 0 || end < 0) return '';",
|
||||
' let result = text.slice(begin + RESULT_BEGIN.length, end).trim();',
|
||||
" if (!result) return '';",
|
||||
' if (result.length > MAX_RESULT_CHARS) {',
|
||||
' const half = Math.floor(MAX_RESULT_CHARS / 2);',
|
||||
" result = result.slice(0, half) + '\\n\\n[report truncated by Codeman]\\n\\n' + result.slice(-half);",
|
||||
' }',
|
||||
" return '\\n\\nCompleted task report:\\n<codeman-background-result>\\n' + result + '\\n</codeman-background-result>';",
|
||||
" } catch { return ''; }",
|
||||
'}',
|
||||
'function inspect(text) {',
|
||||
' for (const line of text.split(/\\r?\\n/)) {',
|
||||
' if (!line.includes(taskId)) continue;',
|
||||
' let entry;',
|
||||
' try { entry = JSON.parse(line); } catch { continue; }',
|
||||
" if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;",
|
||||
" if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue' || typeof entry.content !== 'string') continue;",
|
||||
" if (!entry.content.includes('<task-id>' + taskId + '</task-id>')) continue;",
|
||||
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
|
||||
' if (!status) continue;',
|
||||
' const output = entry.content.match(/<output-file>([^<]+)<\\/output-file>/i);',
|
||||
" const location = output ? ' Read ' + output[1] + ' and' : '';",
|
||||
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');",
|
||||
" const outputPath = output ? output[1].trim() : '';",
|
||||
" const location = outputPath ? ' Read ' + outputPath + ' and' : '';",
|
||||
' const result = readMarkedResult(outputPath);',
|
||||
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.' + result);",
|
||||
' process.exit(2);',
|
||||
' }',
|
||||
'}',
|
||||
'function poll() {',
|
||||
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
|
||||
'function pollTranscript(transcript) {',
|
||||
' try {',
|
||||
' const size = fs.statSync(transcriptPath).size;',
|
||||
" if (size < position) { position = 0; carry = ''; }",
|
||||
' if (size > position) {',
|
||||
' const length = Math.min(size - position, 1048576);',
|
||||
' const size = fs.statSync(transcript.path).size;',
|
||||
" if (size < transcript.position) { transcript.position = 0; transcript.carry = ''; }",
|
||||
' if (size > transcript.position) {',
|
||||
' const length = Math.min(size - transcript.position, 1048576);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(transcriptPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, position);',
|
||||
" const fd = fs.openSync(transcript.path, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, transcript.position);',
|
||||
' fs.closeSync(fd);',
|
||||
' position += bytes;',
|
||||
" carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
|
||||
' inspect(carry);',
|
||||
' transcript.position += bytes;',
|
||||
" transcript.carry = (transcript.carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
|
||||
' inspect(transcript.carry);',
|
||||
' }',
|
||||
' } catch {}',
|
||||
'}',
|
||||
'function poll() {',
|
||||
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
|
||||
' for (const transcript of transcripts) pollTranscript(transcript);',
|
||||
' setTimeout(poll, 1000);',
|
||||
'}',
|
||||
'poll();',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a Claude subagent alive while its Monitor or background Bash work is live.
|
||||
* Claude otherwise can publish the worker's last progress sentence as an Agent
|
||||
* result when one watcher ends, even if other tracked tasks are still running.
|
||||
*/
|
||||
export function generateSubagentStopGuardScript(): string {
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
`const ${SUBAGENT_STOP_GUARD_MARKER} = true;`,
|
||||
'let input = {};',
|
||||
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
|
||||
"const transcriptPath = typeof input.agent_transcript_path === 'string' ? input.agent_transcript_path : '';",
|
||||
'if (!transcriptPath) process.exit(0);',
|
||||
'let text;',
|
||||
'try {',
|
||||
' const size = fs.statSync(transcriptPath).size;',
|
||||
' const length = Math.min(size, 16 * 1024 * 1024);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(transcriptPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, size - length);',
|
||||
' fs.closeSync(fd);',
|
||||
" text = buffer.subarray(0, bytes).toString('utf8');",
|
||||
'} catch { process.exit(0); }',
|
||||
'const launched = new Set();',
|
||||
'const finished = new Set();',
|
||||
'function inspectToolResult(value) {',
|
||||
" const serialized = typeof value === 'string' ? value : JSON.stringify(value ?? '');",
|
||||
' for (const match of serialized.matchAll(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/gi)) launched.add(match[1]);',
|
||||
' for (const match of serialized.matchAll(/Monitor started \\(task ([A-Za-z0-9_-]+)/gi)) launched.add(match[1]);',
|
||||
'}',
|
||||
'function inspectNotifications(value) {',
|
||||
" if (typeof value !== 'string' || !value.includes('<task-notification>')) return;",
|
||||
' for (const match of value.matchAll(/<task-notification>([\\s\\S]*?)<\\/task-notification>/gi)) {',
|
||||
' const body = match[1];',
|
||||
' const id = body.match(/<task-id>([^<]+)<\\/task-id>/i);',
|
||||
' const status = body.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
|
||||
' if (id && status) finished.add(id[1].trim());',
|
||||
' }',
|
||||
'}',
|
||||
'for (const line of text.split(/\\r?\\n/)) {',
|
||||
' let entry;',
|
||||
' try { entry = JSON.parse(line); } catch { continue; }',
|
||||
' const content = entry && entry.message ? entry.message.content : undefined;',
|
||||
' if (Array.isArray(content)) {',
|
||||
' for (const block of content) {',
|
||||
" if (block && block.type === 'tool_result') inspectToolResult(block.content);",
|
||||
" if (block && block.type === 'text') inspectNotifications(block.text);",
|
||||
' }',
|
||||
' } else {',
|
||||
' inspectNotifications(content);',
|
||||
' }',
|
||||
' inspectNotifications(entry && entry.content);',
|
||||
'}',
|
||||
'function findLiveTasks(candidates) {',
|
||||
' const live = new Set();',
|
||||
" if (candidates.size === 0 || !fs.existsSync('/proc')) return live;",
|
||||
' let processIds;',
|
||||
" try { processIds = fs.readdirSync('/proc').filter((name) => /^\\d+$/.test(name)); } catch { return live; }",
|
||||
' for (const processId of processIds) {',
|
||||
" for (const descriptor of ['0', '1', '2']) {",
|
||||
' let target;',
|
||||
" try { target = fs.readlinkSync('/proc/' + processId + '/fd/' + descriptor); } catch { continue; }",
|
||||
' const match = target.match(/[\\/]tasks[\\/]([A-Za-z0-9_-]+)\\.output(?: \\(deleted\\))?$/);',
|
||||
' if (match && candidates.has(match[1])) live.add(match[1]);',
|
||||
' }',
|
||||
' if (live.size === candidates.size) break;',
|
||||
' }',
|
||||
' return live;',
|
||||
'}',
|
||||
'const unfinished = new Set([...launched].filter((taskId) => !finished.has(taskId)));',
|
||||
'const active = [...findLiveTasks(unfinished)];',
|
||||
'if (active.length === 0) process.exit(0);',
|
||||
'const shown = active.slice(0, 8);',
|
||||
"const suffix = active.length > shown.length ? ' and ' + (active.length - shown.length) + ' more' : '';",
|
||||
'process.stdout.write(JSON.stringify({',
|
||||
" decision: 'block',",
|
||||
" reason: 'You still own active background work (' + shown.join(', ') + suffix + '). Do not return an intermediate progress message as your final report. Process the task notifications or keep actively polling until every task completes, then return one complete summary.',",
|
||||
'}));',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
|
||||
@@ -173,7 +307,11 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
const curlCmd = (event: HookEventType) =>
|
||||
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
||||
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||
// `-k`, same as the statusline exporter: CODEMAN_API_URL is loopback HTTPS with
|
||||
// a self-signed cert on --https/tailscale installs. Without it curl exits 60,
|
||||
// the `|| true` swallows it, and ALL SIX hook events die silently: respawn loses
|
||||
// its definitive idle signals and the wait endpoints lose stop/blocked.
|
||||
`curl -sk -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- ` +
|
||||
@@ -200,6 +338,18 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
SubagentStop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', generateSubagentStopGuardScript()],
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
TeammateIdle: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
@@ -231,8 +381,12 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
function isCodemanHookHandler(value: unknown): boolean {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
// Prefix, not the versioned marker: older script versions must still be ours.
|
||||
return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX);
|
||||
// Prefixes, not versioned markers: older script versions must still be ours.
|
||||
return (
|
||||
serialized.includes('/api/hook-event') ||
|
||||
serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX) ||
|
||||
serialized.includes(SUBAGENT_STOP_GUARD_MARKER_PREFIX)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -426,6 +580,49 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an explicitly managed case has the current Codeman hooks.
|
||||
*
|
||||
* Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid
|
||||
* user-owned settings file. It is therefore reserved for case quick-starts,
|
||||
* where the user has explicitly asked Codeman to manage that workspace. A
|
||||
* malformed existing file is left untouched rather than replaced.
|
||||
*
|
||||
* ⚠️ It has NO production call site: PR #233 landed it with the hook scripts and never
|
||||
* wired it up, and knip can't flag it (`test/**` are entry points, so its tests count as
|
||||
* a use). Kept anyway, because it is redundant with neither sibling: `writeHooksConfig`
|
||||
* REPLACES a malformed settings file and rewrites unconditionally, and
|
||||
* `refreshStaleCodemanHooks` deliberately never adds hooks to a case that has none. The
|
||||
* one place it fits is quick-start's existing-case branch in session-routes.ts, and
|
||||
* moving that branch onto this function is a POLICY change (hooks would come back for a
|
||||
* user who deleted them from their case, and linked cases would start getting a hooks
|
||||
* block they have never had), so that call is left to the owner rather than made here.
|
||||
*/
|
||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
|
||||
existing = parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return;
|
||||
}
|
||||
|
||||
const generated = generateHooksConfig();
|
||||
const hooks = mergeCodemanHooks(existing.hooks, generated.hooks);
|
||||
if (JSON.stringify(existing.hooks ?? {}) === JSON.stringify(hooks)) return;
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify({ ...existing, hooks }, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal a case's Codeman-owned hooks block.
|
||||
*
|
||||
@@ -433,8 +630,11 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
|
||||
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
|
||||
* Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either
|
||||
* stale shape on launch so existing cases gain both current behaviors.
|
||||
* Older Codeman blocks also lack the current background Bash async-rewake hook or the
|
||||
* SubagentStop guard. A further stale shape: hook curls without `-k`, which exit 60 on
|
||||
* every --https/tailscale install (the cert is self-signed), swallowed by the hooks'
|
||||
* own `|| true` — all six hook events die silently. Refresh any of these stale shapes
|
||||
* on launch so existing cases heal.
|
||||
*
|
||||
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
|
||||
@@ -457,7 +657,12 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
||||
// absence on our own hooks means they predate COD-54 and need regenerating.
|
||||
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
|
||||
const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER);
|
||||
if (!isOurs || (hasSecret && hasBackgroundWake)) return;
|
||||
// The pre--k curl shape: `curl -sk -X POST` does not contain `curl -s -X POST`
|
||||
// as a substring, so this cleanly identifies hook curls that die with exit 60
|
||||
// on a self-signed HTTPS install.
|
||||
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
|
||||
const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER);
|
||||
if (!isOurs || (hasSecret && hasBackgroundWake && hasSubagentStopGuard && !hasTlsFlaglessCurl)) return;
|
||||
const generated = generateHooksConfig();
|
||||
const merged = {
|
||||
...existing,
|
||||
@@ -531,3 +736,207 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Agent skill injection ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Version-agnostic ownership prefix for the injected agent skill, same pattern as
|
||||
* `BACKGROUND_WAKE_MARKER_PREFIX`: ownership is decided on the prefix so a wording
|
||||
* change in the full marker cannot disown every previously injected copy.
|
||||
*/
|
||||
const AGENT_SKILL_MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
/**
|
||||
* Marker appended to the injected SKILL.md. Its presence is what makes a copy OURS:
|
||||
* install/refresh/remove all refuse to touch a `skills/codeman` whose SKILL.md lacks
|
||||
* it, so a user's hand-authored or hand-edited-and-de-marked skill is never clobbered.
|
||||
*/
|
||||
const AGENT_SKILL_MARKER = `${AGENT_SKILL_MARKER_PREFIX}: installed by Codeman; edits are overwritten while the agent-skill setting is on -->`;
|
||||
|
||||
/**
|
||||
* Packaged source of the skill: `skills/codeman/` at the package root. Resolved
|
||||
* relative to this module so it works from `src/` (tsx dev), `dist/` (tsc build),
|
||||
* and an npm install (`files` includes `skills`), all of which sit one level below
|
||||
* the package root.
|
||||
*/
|
||||
function agentSkillSourceDir(): string {
|
||||
return join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'codeman');
|
||||
}
|
||||
|
||||
interface AgentSkillFile {
|
||||
/** Path relative to the target skill dir (e.g. `reference/endpoints.md`). */
|
||||
relPath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the packaged skill: SKILL.md (marker appended) plus every markdown file
|
||||
* under `reference/`. Enumerated from disk rather than a hardcoded manifest so a
|
||||
* new reference file ships without touching this module.
|
||||
*/
|
||||
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
|
||||
const src = agentSkillSourceDir();
|
||||
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8');
|
||||
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
|
||||
let referenceNames: string[] = [];
|
||||
try {
|
||||
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
|
||||
} catch {
|
||||
// no reference dir in the source; SKILL.md alone is still a valid skill
|
||||
}
|
||||
for (const name of referenceNames) {
|
||||
files.push({ relPath: join('reference', name), content: await readFile(join(src, 'reference', name), 'utf-8') });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish one skill file with a temp + rename, never a bare overwrite.
|
||||
*
|
||||
* Claude Code reads SKILL.md whole when it loads the skill, so an in-place rewrite of
|
||||
* the file (20KB+, several write() syscalls) lets a load that lands mid-write see a
|
||||
* TRUNCATED skill. rename() swaps the finished file in one step, so a
|
||||
* reader sees either the old copy or the new one. The pid+random temp name matters
|
||||
* because `codeman skill install` writes these same paths from a DIFFERENT process than
|
||||
* the server, where the in-process lock cannot help: a shared temp name would let the
|
||||
* two tear each other's payload (same reasoning as user-store.ts).
|
||||
*/
|
||||
async function writeSkillFileAtomic(target: string, content: string): Promise<void> {
|
||||
// `.tmp` last, so a leftover temp is never picked up as a `.md` skill file.
|
||||
const tmpPath = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
try {
|
||||
await writeFile(tmpPath, content);
|
||||
await rename(tmpPath, target);
|
||||
} catch (err) {
|
||||
await unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function isSymlink(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await lstat(path)).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** What an install/remove actually did, so callers (CLI, logs) can say so. */
|
||||
export type AgentSkillApplyResult =
|
||||
| 'installed' // fresh copy written
|
||||
| 'refreshed' // our copy was stale and got rewritten
|
||||
| 'unchanged' // our copy already matches the packaged source
|
||||
| 'removed' // our copy deleted
|
||||
| 'absent' // nothing there to remove
|
||||
| 'foreign' // a copy exists but is not ours; left untouched
|
||||
| 'symlink'; // the skill dir (or its parent) is a symlink; left untouched
|
||||
|
||||
/**
|
||||
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
|
||||
* directory, e.g. `<case>/.claude/skills/codeman` or `~/.claude/skills/codeman`).
|
||||
*
|
||||
* Refuses two shapes rather than writing through them:
|
||||
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
|
||||
* layout (`.claude/skills/codeman -> ../../skills/codeman`) would otherwise have
|
||||
* the injector overwrite the repo source through the link;
|
||||
* - a FOREIGN copy (SKILL.md present without our marker): that is the user's own
|
||||
* skill, and per the statusLine rule we never clobber what we did not write.
|
||||
*
|
||||
* Idempotent and cheap: unchanged files are not rewritten, so calling on every
|
||||
* session create causes no mtime churn.
|
||||
*
|
||||
* Serialized on the skill dir through the same lock the settings writers use: two
|
||||
* sessions created at once in one repo both inject this skill, and interleaving their
|
||||
* ownership read with the other's write reports a bogus result (an 'unchanged' for a
|
||||
* copy the other writer had not finished). Writes go out via temp + rename, which is
|
||||
* what protects a concurrent skill LOAD, in this process or the CLI's.
|
||||
*/
|
||||
export async function installAgentSkillInto(skillDir: string): Promise<AgentSkillApplyResult> {
|
||||
return withSettingsLock(skillDir, async () => {
|
||||
if ((await isSymlink(dirname(skillDir))) || (await isSymlink(skillDir))) return 'symlink';
|
||||
|
||||
let existing: string | null = null;
|
||||
try {
|
||||
existing = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
} catch {
|
||||
// absent: fresh install
|
||||
}
|
||||
if (existing !== null && !existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||
|
||||
const files = await readAgentSkillSource();
|
||||
let changed = false;
|
||||
for (const file of files) {
|
||||
const target = join(skillDir, file.relPath);
|
||||
let current: string | null = null;
|
||||
try {
|
||||
current = await readFile(target, 'utf-8');
|
||||
} catch {
|
||||
// missing: will be written
|
||||
}
|
||||
if (current === file.content) continue;
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeSkillFileAtomic(target, file.content);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return 'unchanged';
|
||||
return existing === null ? 'installed' : 'refreshed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
|
||||
* refusals as the install path. Deletes only files the packaged source would have
|
||||
* written (never `rm -rf`, so a user's extra files in the directory survive), then
|
||||
* prunes the directories bottom-up if they emptied.
|
||||
*
|
||||
* Shares the install path's per-dir lock so an uninstall can't run between an install's
|
||||
* ownership read and its writes, which would leave half the skill back on disk.
|
||||
*/
|
||||
export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkillApplyResult> {
|
||||
return withSettingsLock(skillDir, async () => {
|
||||
if ((await isSymlink(dirname(skillDir))) || (await isSymlink(skillDir))) return 'symlink';
|
||||
|
||||
let existing: string | null = null;
|
||||
try {
|
||||
existing = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
} catch {
|
||||
return 'absent';
|
||||
}
|
||||
if (!existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||
|
||||
// Manifest-based, with SKILL.md as the fallback when the packaged source is
|
||||
// unreadable: removal must still work on an install whose skills/ dir went missing.
|
||||
const files = await readAgentSkillSource().catch((): AgentSkillFile[] => [{ relPath: 'SKILL.md', content: '' }]);
|
||||
for (const file of files) {
|
||||
await unlink(join(skillDir, file.relPath)).catch(() => {});
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty
|
||||
return 'removed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove the Codeman agent skill in `<case>/.claude/skills/codeman`,
|
||||
* mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled`
|
||||
* app setting (default OFF); callers gate on Claude mode, since the skill is discovered
|
||||
* via `.claude/skills/`, which only Claude Code reads.
|
||||
*
|
||||
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one `.claude/`
|
||||
* dir, so a single create while the setting is off must not yank the skill out from
|
||||
* under other live sessions.
|
||||
*
|
||||
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
|
||||
* no server-side toggle-off sweep (it would have to walk every case, including ones
|
||||
* with live sessions, and would hit exactly the shared-`.claude/` hazard above), so
|
||||
* already-injected copies stay on disk until removed per case with
|
||||
* `codeman skill uninstall --case <name>`. The `enabled: false` branch here backs that
|
||||
* CLI and the tests; it has no server call site. Keep the README's Agent Skill note in
|
||||
* sync if this ever changes.
|
||||
*/
|
||||
export async function applyAgentSkill(casePath: string, enabled: boolean): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Bounded descendant walk over a process-tree snapshot.
|
||||
*
|
||||
* Split out of `tmux-manager.ts` so the traversal can be unit-tested directly. It
|
||||
* previously lived as a private method, which meant the regression test had to keep
|
||||
* its own copy of the algorithm — a test that passes while the shipped code rots.
|
||||
*
|
||||
* ## The incident this guards against
|
||||
*
|
||||
* On 2026-07-30 an unbounded version of this walk took a machine down. It ran
|
||||
* `pgrep -P <pid>` once per node and recursed with no visited set, no depth limit and
|
||||
* no node cap. Across ~28 adopted tmux trees the fan-out exploded, and because each
|
||||
* `pgrep` blocks in the WSL kernel while reading `/proc/<pid>/cgroup`, none of them
|
||||
* returned while the walk kept spawning more. Result: ~13,000 `pgrep` processes stuck
|
||||
* in D-state out of ~39,000 total, load average above 13,000, and a machine only
|
||||
* recoverable by restarting WSL — which cost every running session.
|
||||
*
|
||||
* Three properties make that impossible, and each has a test:
|
||||
* 1. a cycle terminates instead of looping (stale snapshots can contain one),
|
||||
* 2. depth is capped,
|
||||
* 3. node count is capped.
|
||||
*
|
||||
* The fourth property — spawning nothing per node — is structural: this function
|
||||
* takes a snapshot and cannot spawn anything at all.
|
||||
*
|
||||
* @module proc-tree
|
||||
*/
|
||||
|
||||
/** Maximum generations to descend. Deeper than any real agent process tree. */
|
||||
export const PROC_WALK_MAX_DEPTH = 10;
|
||||
|
||||
/** Hard ceiling on collected descendants. A backstop, not an expected limit. */
|
||||
export const PROC_WALK_MAX_NODES = 500;
|
||||
|
||||
export interface WalkOptions {
|
||||
maxDepth?: number;
|
||||
maxNodes?: number;
|
||||
/**
|
||||
* Called once when a cap truncated the result, with which cap it was. Both are
|
||||
* reported: a silent depth cap would hide a deep tree just as effectively as a
|
||||
* silent node cap hides a wide one, and the whole point of this module is that
|
||||
* truncation is visible rather than mysterious.
|
||||
*/
|
||||
onTruncated?: (pid: number, cap: number, reason: 'nodes' | 'depth') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* All descendants of `pid`, breadth-first and bounded.
|
||||
*
|
||||
* @param pid root of the walk; never included in the result
|
||||
* @param byParent parent pid → child pids, from ONE `ps` snapshot
|
||||
*/
|
||||
export function collectDescendants(
|
||||
pid: number,
|
||||
byParent: ReadonlyMap<number, readonly number[]>,
|
||||
opts: WalkOptions = {}
|
||||
): number[] {
|
||||
const maxDepth = opts.maxDepth ?? PROC_WALK_MAX_DEPTH;
|
||||
const maxNodes = opts.maxNodes ?? PROC_WALK_MAX_NODES;
|
||||
|
||||
const out: number[] = [];
|
||||
const visited = new Set<number>([pid]);
|
||||
let frontier = [pid];
|
||||
|
||||
for (let depth = 0; depth < maxDepth && frontier.length; depth += 1) {
|
||||
const next: number[] = [];
|
||||
for (const parent of frontier) {
|
||||
for (const child of byParent.get(parent) ?? []) {
|
||||
if (visited.has(child)) continue; // a real tree has no cycles, a stale
|
||||
visited.add(child); // snapshot can still produce one
|
||||
out.push(child);
|
||||
next.push(child);
|
||||
if (out.length >= maxNodes) {
|
||||
opts.onTruncated?.(pid, maxNodes, 'nodes');
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
// Ran out of generations while descendants were still queued: the tree is
|
||||
// deeper than the cap and the result is incomplete.
|
||||
if (depth === maxDepth - 1 && frontier.length > 0) {
|
||||
opts.onTruncated?.(pid, maxDepth, 'depth');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @fileoverview `codeman service install|uninstall|status`: write and load the
|
||||
* systemd user unit (Linux) or LaunchAgent (macOS) that supervises `codeman web`.
|
||||
*
|
||||
* This is the "always running" half of issue #231, next to the "detached right
|
||||
* now" half in daemon-control.ts. `install.sh` already does this for people who
|
||||
* install with the one-liner; this exists for `npm i -g aicodeman` users, who
|
||||
* otherwise have to hand-write a plist.
|
||||
*
|
||||
* Two details are load-bearing and easy to get wrong by hand:
|
||||
*
|
||||
* - **PATH.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and systemd's
|
||||
* user manager is nearly as bare, so a Homebrew or nvm `node`, `tmux` or
|
||||
* `claude` is simply not found and sessions fail in a way that reads as a
|
||||
* Codeman bug. The unit therefore carries the PATH of the shell that ran the
|
||||
* install, with the running node's own directory in front.
|
||||
* - **The job name.** It is the one `install.sh` and the self-updater already use
|
||||
* (config/service-names.ts), so re-running install.sh later updates this unit
|
||||
* instead of supervising a second copy of the server.
|
||||
*
|
||||
* Secrets are deliberately NOT written here. `CODEMAN_PASSWORD` in the installing
|
||||
* shell is not copied into the unit; the caller is told where to add it instead,
|
||||
* because a unit file is long-lived, world-readable by default, and gets copied
|
||||
* into bug reports.
|
||||
*
|
||||
* The file writers are pure string builders so they can be unit-tested without
|
||||
* touching launchctl/systemctl.
|
||||
*
|
||||
* @module service-installer
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from './config/service-names.js';
|
||||
import { CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
buildWebArgs,
|
||||
logFilePath,
|
||||
probeServer,
|
||||
type WebLaunchOptions,
|
||||
} from './daemon-control.js';
|
||||
|
||||
export type ServiceKind = 'launchd' | 'systemd';
|
||||
|
||||
/** Everything a unit file needs, resolved from the environment by the caller. */
|
||||
export interface ServicePlan {
|
||||
kind: ServiceKind;
|
||||
/** systemd unit filename or launchd label. */
|
||||
name: string;
|
||||
nodePath: string;
|
||||
/** Runner flags carried over from the current process (tsx loader in dev). */
|
||||
execArgv: string[];
|
||||
scriptPath: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
logPath: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export interface ServiceActionResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
/** Path of the unit/plist that was written or removed. */
|
||||
unitPath?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ServiceStatusResult {
|
||||
kind: ServiceKind | null;
|
||||
name: string;
|
||||
unitPath: string;
|
||||
installed: boolean;
|
||||
loaded: boolean;
|
||||
responding: boolean;
|
||||
version?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Directories worth having on PATH even when the installing shell lacked them. */
|
||||
const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure builders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** XML text escaping for plist `<string>` values. */
|
||||
export function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* PATH for the supervised process: the running node's directory first (so an nvm
|
||||
* or Homebrew node is used rather than whatever the supervisor finds), then the
|
||||
* installing shell's PATH, then the fallbacks that are still missing.
|
||||
*
|
||||
* `node_modules/.bin` entries are dropped. npm and npx inject those for the
|
||||
* lifetime of one command, and baking a project's local bin dir into a unit file
|
||||
* that outlives the checkout is how a service ends up running a binary the
|
||||
* operator deleted months ago.
|
||||
*/
|
||||
export function buildServicePath(nodeDir: string, currentPath: string, home: string): string {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
const push = (dir: string) => {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
if (/(^|\/)node_modules\/\.bin\/?$/.test(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
};
|
||||
|
||||
push(nodeDir);
|
||||
for (const dir of currentPath.split(':')) push(dir);
|
||||
push(join(home, '.local', 'bin'));
|
||||
for (const dir of FALLBACK_PATH_DIRS) push(dir);
|
||||
return ordered.join(':');
|
||||
}
|
||||
|
||||
/** Environment written into the unit. Never includes secrets (see module docs). */
|
||||
export function buildServiceEnv(
|
||||
nodeDir: string,
|
||||
currentPath: string,
|
||||
home: string,
|
||||
lang?: string
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
PATH: buildServicePath(nodeDir, currentPath, home),
|
||||
HOME: home,
|
||||
LANG: lang || 'en_US.UTF-8',
|
||||
};
|
||||
if (CODEMAN_INSTANCE) env.CODEMAN_INSTANCE = CODEMAN_INSTANCE;
|
||||
return env;
|
||||
}
|
||||
|
||||
/** systemd accepts double-quoted values; escape the two characters that matter. */
|
||||
export function systemdQuote(value: string): string {
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function buildLaunchAgentPlist(plan: ServicePlan): string {
|
||||
const programArguments = [plan.nodePath, ...plan.execArgv, plan.scriptPath, ...plan.args]
|
||||
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
||||
.join('\n');
|
||||
const environment = Object.entries(plan.env)
|
||||
.map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${xmlEscape(plan.name)}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${programArguments}
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
${environment}
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${xmlEscape(plan.workingDir)}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${xmlEscape(plan.logPath)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${xmlEscape(plan.logPath)}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSystemdUnit(plan: ServicePlan): string {
|
||||
const execStart = [plan.nodePath, ...plan.execArgv, plan.scriptPath, ...plan.args]
|
||||
.map((arg) => (/[\s"'\\]/.test(arg) ? systemdQuote(arg) : arg))
|
||||
.join(' ');
|
||||
const environment = Object.entries(plan.env)
|
||||
.map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
|
||||
.join('\n');
|
||||
|
||||
return `[Unit]
|
||||
Description=Codeman Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${plan.workingDir}
|
||||
ExecStart=${execStart}
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Agents keep running in tmux when the server restarts, so only signal the
|
||||
# server itself.
|
||||
KillMode=process
|
||||
${environment}
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=codeman
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Environment resolution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function detectServiceKind(): ServiceKind | null {
|
||||
if (process.platform === 'darwin') return 'launchd';
|
||||
if (process.platform === 'linux') return 'systemd';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function unitPathFor(kind: ServiceKind): string {
|
||||
return kind === 'launchd'
|
||||
? join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`)
|
||||
: join(homedir(), '.config', 'systemd', 'user', SYSTEMD_UNIT);
|
||||
}
|
||||
|
||||
function entryScript(): string {
|
||||
const script = process.argv[1];
|
||||
if (!script) throw new Error('cannot determine the codeman entry script to supervise');
|
||||
return script;
|
||||
}
|
||||
|
||||
/** Resolve a full plan from the current process and the requested web options. */
|
||||
export function resolveServicePlan(kind: ServiceKind, options: WebLaunchOptions): ServicePlan {
|
||||
const home = homedir();
|
||||
return {
|
||||
kind,
|
||||
name: kind === 'launchd' ? LAUNCHD_LABEL : SYSTEMD_UNIT,
|
||||
nodePath: process.execPath,
|
||||
execArgv: [...process.execArgv],
|
||||
scriptPath: entryScript(),
|
||||
args: buildWebArgs(options),
|
||||
env: buildServiceEnv(dirname(process.execPath), process.env.PATH || '', home, process.env.LANG),
|
||||
logPath: logFilePath(),
|
||||
workingDir: home,
|
||||
};
|
||||
}
|
||||
|
||||
function run(command: string, args: string[]): { ok: boolean; output: string } {
|
||||
try {
|
||||
const output = execFileSync(command, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { ok: true, output: output.trim() };
|
||||
} catch (err) {
|
||||
const e = err as { stderr?: Buffer | string; message?: string };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8');
|
||||
return { ok: false, output: (stderr || e.message || '').trim() };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Install / uninstall / status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write the unit, load it, and confirm the server actually answers before
|
||||
* reporting success. `launchctl load` and `systemctl enable` are both quiet about
|
||||
* a job that starts and immediately dies, which is the whole reason install.sh
|
||||
* verifies too.
|
||||
*/
|
||||
export async function installService(options: WebLaunchOptions): Promise<ServiceActionResult> {
|
||||
const kind = detectServiceKind();
|
||||
if (!kind) {
|
||||
return { ok: false, message: `no supported supervisor on ${process.platform}; use \`codeman web -d\` instead` };
|
||||
}
|
||||
|
||||
const plan = resolveServicePlan(kind, options);
|
||||
const unitPath = unitPathFor(kind);
|
||||
const warnings: string[] = [];
|
||||
mkdirSync(dirname(unitPath), { recursive: true });
|
||||
|
||||
if (kind === 'launchd') {
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
// Unload any previous copy first, otherwise bootstrap fails with "service
|
||||
// already loaded" and leaves the OLD job running against the NEW file.
|
||||
run('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]);
|
||||
writeFileSync(unitPath, buildLaunchAgentPlist(plan), { encoding: 'utf-8', mode: 0o600 });
|
||||
const bootstrap = run('launchctl', ['bootstrap', `gui/${uid}`, unitPath]);
|
||||
if (!bootstrap.ok) {
|
||||
const legacy = run('launchctl', ['load', unitPath]);
|
||||
if (!legacy.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
unitPath,
|
||||
message: `wrote ${unitPath} but launchctl refused to load it: ${bootstrap.output}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
writeFileSync(unitPath, buildSystemdUnit(plan), { encoding: 'utf-8', mode: 0o600 });
|
||||
const reload = run('systemctl', ['--user', 'daemon-reload']);
|
||||
if (!reload.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
unitPath,
|
||||
message: `wrote ${unitPath} but \`systemctl --user daemon-reload\` failed: ${reload.output}`,
|
||||
};
|
||||
}
|
||||
const enable = run('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT]);
|
||||
if (!enable.ok) {
|
||||
return { ok: false, unitPath, message: `wrote ${unitPath} but enabling it failed: ${enable.output}` };
|
||||
}
|
||||
// Without lingering the unit stops at logout, which is exactly what someone
|
||||
// installing a service does not want. Best effort: it needs polkit rights.
|
||||
const linger = run('loginctl', ['enable-linger', userInfo().username]);
|
||||
if (!linger.ok) {
|
||||
warnings.push(
|
||||
`could not enable lingering, so the service will stop when you log out. Run: sudo loginctl enable-linger ${userInfo().username}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const url = buildBaseUrl(options);
|
||||
const statusUrl = buildStatusUrl(options);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const probe = await probeServer(statusUrl, 1000);
|
||||
if (probe.up) {
|
||||
return { ok: true, unitPath, warnings, message: `service installed and responding at ${url}` };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
const hint =
|
||||
kind === 'launchd' ? `tail -20 ${plan.logPath}` : `journalctl --user -u ${SYSTEMD_UNIT} -n 20 --no-pager`;
|
||||
return {
|
||||
ok: false,
|
||||
unitPath,
|
||||
warnings,
|
||||
message: `wrote and loaded ${unitPath}, but nothing answered ${url} within 30s. Check: ${hint}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function uninstallService(): ServiceActionResult {
|
||||
const kind = detectServiceKind();
|
||||
if (!kind) return { ok: false, message: `no supported supervisor on ${process.platform}` };
|
||||
|
||||
const unitPath = unitPathFor(kind);
|
||||
if (!existsSync(unitPath)) {
|
||||
return { ok: false, unitPath, message: `no service installed at ${unitPath}` };
|
||||
}
|
||||
|
||||
if (kind === 'launchd') {
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
const bootout = run('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]);
|
||||
if (!bootout.ok) run('launchctl', ['unload', unitPath]);
|
||||
} else {
|
||||
run('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(unitPath);
|
||||
} catch (err) {
|
||||
return { ok: false, unitPath, message: `stopped the service but could not remove ${unitPath}: ${String(err)}` };
|
||||
}
|
||||
if (kind === 'systemd') run('systemctl', ['--user', 'daemon-reload']);
|
||||
|
||||
return { ok: true, unitPath, message: `service stopped and ${unitPath} removed. Your tmux sessions are untouched.` };
|
||||
}
|
||||
|
||||
export async function serviceStatus(options: WebLaunchOptions): Promise<ServiceStatusResult> {
|
||||
const kind = detectServiceKind();
|
||||
const url = buildBaseUrl(options);
|
||||
if (!kind) {
|
||||
return { kind: null, name: '', unitPath: '', installed: false, loaded: false, responding: false, url };
|
||||
}
|
||||
|
||||
const unitPath = unitPathFor(kind);
|
||||
const name = kind === 'launchd' ? LAUNCHD_LABEL : SYSTEMD_UNIT;
|
||||
const installed = existsSync(unitPath);
|
||||
const loaded =
|
||||
kind === 'launchd'
|
||||
? run('launchctl', ['list', LAUNCHD_LABEL]).ok
|
||||
: run('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]).output === 'active';
|
||||
const probe = await probeServer(buildStatusUrl(options), 2000);
|
||||
|
||||
return { kind, name, unitPath, installed, loaded, responding: probe.up, version: probe.version, url };
|
||||
}
|
||||
@@ -121,7 +121,10 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
// CODEMAN_API_URL rides in via the process.env spread when the server has
|
||||
// stamped it (WebServer.start()); no fallback: a hardcoded one was the wrong
|
||||
// scheme on HTTPS installs, and a present-with-undefined key would serialize
|
||||
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
@@ -179,7 +182,8 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
|
||||
TERM: 'xterm-256color',
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
// CODEMAN_API_URL rides in via the process.env spread when set; no fallback
|
||||
// (same reasoning as buildClaudeEnv above).
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
|
||||
+26
-6
@@ -2620,20 +2620,23 @@ export class Session extends EventEmitter {
|
||||
* For interactive sessions, this is how you send user input to Claude.
|
||||
* Remember to include `\r` (carriage return) to simulate pressing Enter.
|
||||
*
|
||||
* @param data - The input data to send (text, escape sequences, etc.)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* session.write('hello world'); // Text only, no Enter
|
||||
* session.write('\r'); // Enter key
|
||||
* session.write('ls -la\r'); // Command with Enter
|
||||
* ```
|
||||
*
|
||||
* @param data - The input data to send (text, escape sequences, etc.)
|
||||
* @returns true if the data reached a PTY. A session whose PTY is gone still
|
||||
* discards the data, but it used to do so with no signal at all — which is how
|
||||
* input could disappear while the caller believed it had been delivered.
|
||||
*/
|
||||
write(data: string): void {
|
||||
write(data: string): boolean {
|
||||
this._trackSubmit(data);
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
}
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Conversation tracking ─────────────────────────────────────────────
|
||||
@@ -2691,6 +2694,23 @@ export class Session extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the bookkeeping of {@link shouldApplyInput} for a delivery that failed.
|
||||
*
|
||||
* Without this, the reliable-delivery layer guarantees exactly-once delivery of
|
||||
* something that may never have been delivered: the seq is recorded as applied
|
||||
* BEFORE the write is attempted, so a client retry — the very mechanism the seq
|
||||
* exists for — is rejected as a duplicate and the input is lost for good.
|
||||
*
|
||||
* Only rolls back if `seq` is still the newest recorded one; a later input has
|
||||
* already superseded it and must not be re-opened.
|
||||
*/
|
||||
forgetInputSeq(clientId: string, seq: number): void {
|
||||
if (this._appliedInputSeq.get(clientId) === seq) {
|
||||
this._appliedInputSeq.set(clientId, seq - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends input via the terminal multiplexer's direct input mechanism.
|
||||
*
|
||||
|
||||
+122
-41
@@ -22,7 +22,8 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execSync, exec } from 'node:child_process';
|
||||
import { collectDescendants } from './proc-tree.js';
|
||||
import { execSync, exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -100,6 +101,16 @@ import {
|
||||
// ============================================================================
|
||||
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
|
||||
/** How long a cached process snapshot stays usable. */
|
||||
const PROC_SNAPSHOT_TTL_MS = 2000;
|
||||
|
||||
/**
|
||||
* How long the kill path waits for a fresh snapshot before giving up on it.
|
||||
* Shorter than EXEC_TIMEOUT_MS on purpose: killSession has two further strategies
|
||||
* (process group, tmux kill-session) and must reach them even when `ps` is wedged.
|
||||
*/
|
||||
const PROC_SNAPSHOT_WAIT_MS = 1500;
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from './config/terminal-history.js';
|
||||
|
||||
/**
|
||||
@@ -1578,7 +1589,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
'export CODEMAN_MUX=1',
|
||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||||
// Only exported when the server has stamped the real URL (scheme+host+port,
|
||||
// set in WebServer.start()). A hardcoded fallback here exported the wrong
|
||||
// scheme on HTTPS installs; leaving the variable unset makes in-session
|
||||
// guards fail closed instead of curling a URL that was never right.
|
||||
...(process.env.CODEMAN_API_URL ? [`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL}`] : []),
|
||||
// Path only (not the secret value): hook curl commands cat the file at
|
||||
// execution time, so the COD-54 hook secret stays off the command line.
|
||||
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
|
||||
@@ -2094,27 +2109,102 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Get all child process PIDs recursively
|
||||
private getChildPids(pid: number): number[] {
|
||||
const pids: number[] = [];
|
||||
try {
|
||||
const output = execSync(`pgrep -P ${pid}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (output) {
|
||||
for (const childPid of output
|
||||
.split('\n')
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((p) => !Number.isNaN(p))) {
|
||||
pids.push(childPid);
|
||||
pids.push(...this.getChildPids(childPid));
|
||||
/** One `ps` snapshot of the whole process table, cached briefly. */
|
||||
private static procSnapshot: { at: number; byParent: Map<number, number[]> } | null = null;
|
||||
/** Single-flight guard so a hung `ps` cannot pile up parallel refreshes. */
|
||||
private static procRefresh: { started: number; promise: Promise<Map<number, number[]>> } | null = null;
|
||||
|
||||
/**
|
||||
* Fork ONE `ps` asynchronously and cache the parent -> children map.
|
||||
*
|
||||
* Async on purpose: a synchronous fork here would block the event loop on every
|
||||
* stats tick, and under the procfs pathology this module exists to survive,
|
||||
* `execSync`'s timeout cannot return at all (spawnSync waits for the unkillable
|
||||
* child) — freezing the whole server where a hung async poll only costs staleness.
|
||||
*/
|
||||
private static refreshProcSnapshot(): Promise<Map<number, number[]>> {
|
||||
const inFlight = TmuxManager.procRefresh;
|
||||
// Reuse an in-flight refresh — unless it is old enough to be presumed stuck.
|
||||
if (inFlight && Date.now() - inFlight.started < EXEC_TIMEOUT_MS * 2) return inFlight.promise;
|
||||
|
||||
const started = Date.now();
|
||||
const promise = new Promise<Map<number, number[]>>((resolve) => {
|
||||
execFile('ps', ['-eo', 'pid=,ppid='], { timeout: EXEC_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
|
||||
if (TmuxManager.procRefresh?.started === started) TmuxManager.procRefresh = null;
|
||||
if (err) {
|
||||
// ANY error, not just an empty one: a timed-out or truncated `ps` yields
|
||||
// partial output, and caching that as fresh would make whole subtrees
|
||||
// invisible — including to the kill path. Stale beats wrong.
|
||||
console.error('[TmuxManager] process snapshot failed:', err);
|
||||
resolve(TmuxManager.procSnapshot?.byParent ?? new Map());
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No children or command failed
|
||||
const byParent = new Map<number, number[]>();
|
||||
for (const line of String(out).split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
const pid = parseInt(parts[0], 10);
|
||||
const ppid = parseInt(parts[1], 10);
|
||||
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
|
||||
const list = byParent.get(ppid);
|
||||
if (list) list.push(pid);
|
||||
else byParent.set(ppid, [pid]);
|
||||
}
|
||||
TmuxManager.procSnapshot = { at: Date.now(), byParent };
|
||||
resolve(byParent);
|
||||
});
|
||||
});
|
||||
TmuxManager.procRefresh = { started, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best snapshot WITHOUT forking: returns the cache, kicking off a background
|
||||
* refresh when it has gone stale, and never blocks. Stats and window-title
|
||||
* consumers tolerate data one interval old; nothing that KILLS may use this.
|
||||
*/
|
||||
private childrenByParent(): Map<number, number[]> {
|
||||
const cached = TmuxManager.procSnapshot;
|
||||
if (!cached || Date.now() - cached.at >= PROC_SNAPSHOT_TTL_MS) {
|
||||
void TmuxManager.refreshProcSnapshot();
|
||||
}
|
||||
return pids;
|
||||
return cached?.byParent ?? new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Descendants from a snapshot that is not the cached one — the kill path's variant.
|
||||
*
|
||||
* killSession re-scans for survivors between SIGTERM and SIGKILL, and the wait in
|
||||
* between (200ms) sits far inside the cache TTL (2000ms): reading the cache there
|
||||
* returns the pre-SIGTERM state verbatim, so children spawned since are invisible
|
||||
* and SIGKILL aims at stale PIDs, guarded only by kill(pid, 0) — which cannot
|
||||
* detect PID reuse.
|
||||
*
|
||||
* It forces a refresh rather than guaranteeing recency: an already-running refresh
|
||||
* is reused, so the snapshot can predate this call by up to one `ps` runtime. A
|
||||
* strict postdate guarantee would mean chaining a second `ps` behind every
|
||||
* in-flight one, which is the fork storm this code exists to avoid.
|
||||
*
|
||||
* Bounded by design: waiting forever would freeze killSession before it reaches
|
||||
* its process-group and tmux fallbacks.
|
||||
*/
|
||||
private async getChildPidsFresh(pid: number): Promise<number[]> {
|
||||
let byParent: ReadonlyMap<number, readonly number[]>;
|
||||
try {
|
||||
byParent = await Promise.race([
|
||||
TmuxManager.refreshProcSnapshot(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('proc snapshot timeout')), PROC_SNAPSHOT_WAIT_MS)
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
console.warn('[TmuxManager] process snapshot did not return in time; using the cached one');
|
||||
byParent = TmuxManager.procSnapshot?.byParent ?? new Map<number, number[]>();
|
||||
}
|
||||
return collectDescendants(pid, byParent, {
|
||||
onTruncated: (root, cap, reason) =>
|
||||
console.warn(`[TmuxManager] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
|
||||
});
|
||||
}
|
||||
|
||||
// Check if a process is still alive
|
||||
@@ -2221,7 +2311,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const allPids: number[] = [currentPid];
|
||||
|
||||
// Strategy 1: Kill all child processes recursively
|
||||
let childPids = this.getChildPids(currentPid);
|
||||
let childPids = await this.getChildPidsFresh(currentPid);
|
||||
if (childPids.length > 0) {
|
||||
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
|
||||
allPids.push(...childPids);
|
||||
@@ -2238,7 +2328,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||
|
||||
childPids = this.getChildPids(currentPid);
|
||||
childPids = await this.getChildPidsFresh(currentPid);
|
||||
for (const childPid of childPids) {
|
||||
if (this.isProcessAlive(childPid)) {
|
||||
try {
|
||||
@@ -2452,17 +2542,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||||
|
||||
// From the shared snapshot: this runs per session on every stats tick, and a
|
||||
// pgrep per session was a fork per session per interval.
|
||||
let childCount = 0;
|
||||
try {
|
||||
const childOutput = (
|
||||
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
childCount = parseInt(childOutput, 10) || 0;
|
||||
childCount = (this.childrenByParent().get(session.pid) ?? []).length;
|
||||
} catch {
|
||||
// No children or command failed
|
||||
// No children or snapshot unavailable
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2496,17 +2582,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Step 1: Get descendant PIDs
|
||||
const descendantMap = new Map<number, number[]>();
|
||||
|
||||
const pgrepOutput = (
|
||||
await execAsync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
).stdout.trim();
|
||||
// Derived from the ONE snapshot instead of a shell loop that forks a pgrep
|
||||
// per session — the shape that turned into a fork storm under load.
|
||||
const byParent = this.childrenByParent();
|
||||
const childLines = sessionPids.map((p) => `${p}:${(byParent.get(p) ?? []).join(',')}`).join('\n');
|
||||
|
||||
for (const line of pgrepOutput.split('\n')) {
|
||||
for (const line of childLines.split('\n')) {
|
||||
const [pidStr, childrenStr] = line.split(':');
|
||||
const sessionPid = parseInt(pidStr, 10);
|
||||
if (!Number.isNaN(sessionPid)) {
|
||||
|
||||
@@ -108,12 +108,100 @@ export function getAugmentedPath(): string {
|
||||
return _augmentedPath;
|
||||
}
|
||||
|
||||
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
|
||||
let _claudeVersion: string | null | undefined = undefined;
|
||||
/**
|
||||
* Cache state for the `claude --version` probe.
|
||||
*
|
||||
* `version` is only ever set from a SUCCESSFUL probe and then kept for the
|
||||
* process lifetime (the binary can't change under a running server without a
|
||||
* restart). Failures are tracked separately so they expire.
|
||||
*/
|
||||
export interface ClaudeVersionProbeState {
|
||||
/** Successful probe result; `undefined` until one succeeds. */
|
||||
version?: string;
|
||||
/** Consecutive failed probes (drives the retry backoff). */
|
||||
failures: number;
|
||||
/** Timestamp of the most recent failed probe. */
|
||||
lastFailureAt: number;
|
||||
}
|
||||
|
||||
/** First retry window after a failed probe. */
|
||||
const VERSION_PROBE_BASE_RETRY_MS = 60_000;
|
||||
/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */
|
||||
const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* How long to wait before re-probing after `failures` consecutive failures:
|
||||
* 1min, 2min, 4min… capped at 15min. Exported for tests.
|
||||
*/
|
||||
export function claudeVersionRetryDelayMs(failures: number): number {
|
||||
if (failures <= 0) return 0;
|
||||
return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache policy for the version probe, pure apart from the `state` it mutates
|
||||
* and the injected `probe` (exported so tests can drive it with a fake clock).
|
||||
*
|
||||
* Success is cached forever; FAILURE is not. That asymmetry is the fix for a
|
||||
* real shipped bug: the old cache stored `null` on any exception and guarded on
|
||||
* `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a
|
||||
* PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST
|
||||
* Claude session start left `cliVersion` undefined for EVERY Claude session
|
||||
* until the server restarted. An undefined `cliVersion` silently disables
|
||||
* wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`),
|
||||
* which is the only route to history in repaint mode: a dead wheel on every
|
||||
* device at once, matching the issue #205 retest reports.
|
||||
*
|
||||
* Retries back off so a genuinely absent binary still can't spawn a probe per
|
||||
* session start.
|
||||
*/
|
||||
export function resolveClaudeCliVersion(
|
||||
state: ClaudeVersionProbeState,
|
||||
now: number,
|
||||
probe: () => string | null
|
||||
): string | null {
|
||||
if (state.version !== undefined) return state.version;
|
||||
if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null;
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
version = probe();
|
||||
} catch {
|
||||
version = null;
|
||||
}
|
||||
|
||||
if (version) {
|
||||
state.version = version;
|
||||
state.failures = 0;
|
||||
state.lastFailureAt = 0;
|
||||
return version;
|
||||
}
|
||||
state.failures += 1;
|
||||
state.lastFailureAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 };
|
||||
|
||||
/** One `claude --version` run. Throws on spawn/timeout failure. */
|
||||
function probeClaudeCliVersion(): string | null {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
|
||||
* can't be determined. Runs `claude --version` once and caches the result.
|
||||
* can't be determined. Runs `claude --version` at most once per successful
|
||||
* resolution; failed probes retry with backoff (see `resolveClaudeCliVersion`).
|
||||
*
|
||||
* This is a deterministic alternative to scraping the interactive startup
|
||||
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
|
||||
@@ -122,28 +210,10 @@ let _claudeVersion: string | null | undefined = undefined;
|
||||
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
|
||||
*/
|
||||
export function getClaudeCliVersion(): string | null {
|
||||
if (_claudeVersion !== undefined) return _claudeVersion;
|
||||
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
|
||||
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
|
||||
// it on the session directly.
|
||||
if (process.env.VITEST) {
|
||||
_claudeVersion = null;
|
||||
return _claudeVersion;
|
||||
}
|
||||
try {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
_claudeVersion = match ? match[1] : null;
|
||||
} catch {
|
||||
_claudeVersion = null;
|
||||
}
|
||||
return _claudeVersion;
|
||||
// it on the session directly. Deliberately does NOT touch the cache state:
|
||||
// recording a phantom failure here would be the very poisoning this fixes.
|
||||
if (process.env.VITEST) return null;
|
||||
return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ConfigPort {
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||
getAgentSkillEnabled(): Promise<boolean>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
|
||||
+77
-16
@@ -519,6 +519,10 @@ class CodemanApp {
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
this._fullHistoryRepullInFlight = false;
|
||||
// Sessions whose last re-pull came back THINNER than the live buffer (a
|
||||
// repaint-mode CLI pane, where tmux keeps no history of its own). The pull is
|
||||
// refused for those and retried far more slowly — see _maybeRefetchFullHistory.
|
||||
this._fullHistoryRepullUseless = new Set();
|
||||
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
@@ -695,6 +699,10 @@ class CodemanApp {
|
||||
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
||||
this._localEchoOverlay = null; // created after terminal.open()
|
||||
this._localEchoEnabled = false; // true when setting on + session active
|
||||
// Predictive write-through echo (codex) — created after terminal.open()
|
||||
// from the separate vendor/xterm-predictive-echo.js bundle (may stay null)
|
||||
this._predictiveEcho = null;
|
||||
this._localEchoPolicy = 'off'; // 'buffer' | 'predict' | 'off' (per active session)
|
||||
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
|
||||
|
||||
// Accessibility: Focus trap for modals
|
||||
@@ -823,6 +831,7 @@ class CodemanApp {
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
this._setupTabMiddleClickClose();
|
||||
// Must run before the first session:created can arrive: markSessionTabEntering()
|
||||
// ignores ids until this sets up its state, which is what keeps the tabs
|
||||
// restored on page load from animating.
|
||||
@@ -3016,6 +3025,9 @@ class CodemanApp {
|
||||
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
||||
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
||||
this._localEchoOverlay?.rerender();
|
||||
// Deliberate asymmetry: buffer-mode pending text SURVIVES reconnect (not
|
||||
// yet sent); predictions do not (their keystrokes were already delivered).
|
||||
this._predictiveEcho?.clearPredictions();
|
||||
// Clear pending hooks
|
||||
this.pendingHooks.clear();
|
||||
// Clear parent name cache (prevents stale session name entries accumulating)
|
||||
@@ -3433,14 +3445,19 @@ class CodemanApp {
|
||||
statusEl.className = `tab-status ${status}`;
|
||||
}
|
||||
|
||||
// Update name if changed
|
||||
// Update name if changed. #232: a description (the `: suffix` part of the
|
||||
// name) is the whole tab label; the generated id lives in the tooltip. The
|
||||
// compare targets the DISPLAY text, or a described tab would re-render on
|
||||
// every pass (textContent never equals the full name there).
|
||||
const nameEl = tab.querySelector('.tab-name');
|
||||
if (nameEl && nameEl.textContent !== name) {
|
||||
if (nameEl) {
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (_p && _p.suffix) {
|
||||
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
const _label = _p && _p.suffix ? _p.suffix : name;
|
||||
if (nameEl.textContent !== _label) {
|
||||
nameEl.textContent = _label;
|
||||
tab.title = _p && _p.suffix
|
||||
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
|
||||
: (session.workingDir || '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3485,11 +3502,12 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
} else if (minimizedCount > 0 && !subagentBadgeEl) {
|
||||
// Need to add badge - insert before gear icon
|
||||
// Need to add badge - insert before the action-icon overlay so the
|
||||
// badge stays a direct child of the tab (outside .tab-actions)
|
||||
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
|
||||
const gearEl = tab.querySelector('.tab-gear');
|
||||
if (gearEl) {
|
||||
gearEl.insertAdjacentHTML('beforebegin', badgeHtml);
|
||||
const actionsEl = tab.querySelector('.tab-actions');
|
||||
if (actionsEl) {
|
||||
actionsEl.insertAdjacentHTML('beforebegin', badgeHtml);
|
||||
}
|
||||
} else if (minimizedCount === 0 && subagentBadgeEl) {
|
||||
// Count went to 0 - remove badge
|
||||
@@ -3543,6 +3561,25 @@ class CodemanApp {
|
||||
container.classList.toggle('tabs-auto-wrap', shouldWrap);
|
||||
}
|
||||
|
||||
// Middle-click closes a tab, mirroring browser tab strips. Session tabs go
|
||||
// through requestCloseSession (the same confirm modal as the x button), web
|
||||
// tabs through closeWebviewTab (same as theirs). Delegated on the container:
|
||||
// tabs are re-rendered wholesale, the container is stable.
|
||||
_setupTabMiddleClickClose() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container || this._tabAuxClickBound) return;
|
||||
this._tabAuxClickBound = true;
|
||||
container.addEventListener('auxclick', (e) => {
|
||||
if (e.button !== 1) return;
|
||||
const tab = e.target.closest?.('.session-tab');
|
||||
if (!tab) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (tab.dataset.id) this.requestCloseSession(tab.dataset.id);
|
||||
else if (tab.dataset.webviewId) this.closeWebviewTab?.(tab.dataset.webviewId);
|
||||
});
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -3592,14 +3629,23 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
// #232: a session with a description (the `: suffix` part of its name) shows
|
||||
// JUST the description on the tab; the generated w<n>-<case> id moves to the
|
||||
// tooltip and stays visible in the session settings modal.
|
||||
const parsedName = parseSessionPrefix(name);
|
||||
const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name;
|
||||
const tabTooltip = parsedName && parsedName.suffix
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
@@ -3607,9 +3653,7 @@ class CodemanApp {
|
||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||
${subagentBadge}
|
||||
${ultracodeBadge}
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span></span>
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
}
|
||||
@@ -4090,6 +4134,9 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
this._localEchoOverlay?.clear();
|
||||
// Predictions are ephemeral + already sent: nothing to save/restore
|
||||
// across a tab switch (unlike the buffer overlay's setFlushed machinery)
|
||||
this._predictiveEcho?.clearPredictions();
|
||||
// Prevent _detectBufferText() from picking up Claude's Ink UI text
|
||||
// (status bar, model info, etc.) as "user input" on fresh sessions.
|
||||
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
||||
@@ -4121,6 +4168,13 @@ class CodemanApp {
|
||||
* On demand rather than automatic because that capture is unbounded-ish work: at
|
||||
* the default history limit it can be megabytes, which is fine to pay when the
|
||||
* user is explicitly reaching for history and not fine on every tab switch.
|
||||
*
|
||||
* NEVER a downgrade: for a repaint-mode CLI pane tmux keeps no history of its
|
||||
* own, so the capture can be THINNER than what xterm already holds and the
|
||||
* reset+rewrite below would delete history mid-scroll. `_replayWouldShrinkBuffer`
|
||||
* (terminal-ui.js) is the guard, and a session that produced one useless re-pull
|
||||
* gets a much longer cooldown so a hollow pane stops re-fetching megabytes on
|
||||
* every scroll-up (issue #205, round 2).
|
||||
*/
|
||||
async _maybeRefetchFullHistory() {
|
||||
const sessionId = this.activeSessionId;
|
||||
@@ -4129,7 +4183,8 @@ class CodemanApp {
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return;
|
||||
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
|
||||
if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
|
||||
this._fullHistoryRepullAt.set(sessionId, now);
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
@@ -4138,6 +4193,12 @@ class CodemanApp {
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-refused-downgrade');
|
||||
return;
|
||||
}
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
|
||||
@@ -227,6 +227,7 @@
|
||||
'Redraw Terminal Button': '重绘终端按钮',
|
||||
'Tab Bar': '标签栏',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||
<script defer src="vendor/xterm-predictive-echo.js"></script>
|
||||
<script defer src="vendor/marked.min.js"></script>
|
||||
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
|
||||
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
|
||||
@@ -708,7 +709,10 @@
|
||||
<span class="form-hint">
|
||||
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
|
||||
this lets its JavaScript read this page and call the API that starts agents. Uncheck
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies.
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies. Also
|
||||
uncheck it if Codeman itself sits behind a cookie-authenticated reverse proxy
|
||||
(e.g. Cloudflare Access): a sandboxed frame carries no auth cookie, so its asset
|
||||
and API requests bounce to the login provider and the page loads broken.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -1322,7 +1326,7 @@
|
||||
</div>
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Only Claude sessions forward, so this setting only affects them: Claude redraws the screen in place and keeps almost no local scrollback, so with this ON the wheel has little to scroll and Codeman falls back to paging Claude's own transcript. Codex, Gemini, shell and OpenCode sessions always scroll local scrollback. Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Wheel Scrolls Local History</span>
|
||||
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
|
||||
@@ -1480,6 +1484,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the open-in-new-window (pop-out) button when hovering a session tab">
|
||||
<span class="settings-item-label">Pop-out Button on Tabs</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowTabDetachButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Panels Section -->
|
||||
<div class="settings-section-header">Panels</div>
|
||||
@@ -1630,6 +1641,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Agent Skill</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsAgentSkill">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Give new Claude sessions the Codeman skill (start workers, send prompts, wait for results via the API)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Claude Model</label>
|
||||
<select id="appSettingsClaudeModel" class="form-select">
|
||||
|
||||
@@ -601,6 +601,9 @@ const KeyboardAccessoryBar = {
|
||||
* must be written raw to be interpreted as key presses by Ink. */
|
||||
sendKey(escapeSequence) {
|
||||
if (!app.activeSessionId) return;
|
||||
// Arrows/Esc move the server-side cursor and bypass onData: clear
|
||||
// predictions now instead of waiting out the ~150ms off-row grace.
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
fetch(`/api/sessions/${app.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -209,9 +209,13 @@ const MobileDetection = {
|
||||
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
|
||||
*/
|
||||
const KeyboardHandler = {
|
||||
VIEWPORT_SETTLE_MS: 80,
|
||||
lastViewportHeight: 0,
|
||||
keyboardVisible: false,
|
||||
initialViewportHeight: 0,
|
||||
_viewportSettleTimer: null,
|
||||
_settleScrollToBottom: false,
|
||||
_settlePending: false,
|
||||
|
||||
/** Initialize keyboard handling */
|
||||
init() {
|
||||
@@ -276,6 +280,12 @@ const KeyboardHandler = {
|
||||
window.removeEventListener('scroll', this._windowScrollHandler);
|
||||
this._windowScrollHandler = null;
|
||||
}
|
||||
if (this._viewportSettleTimer) {
|
||||
clearTimeout(this._viewportSettleTimer);
|
||||
this._viewportSettleTimer = null;
|
||||
}
|
||||
this._settleScrollToBottom = false;
|
||||
this._settlePending = false;
|
||||
},
|
||||
|
||||
/** Handle viewport resize (keyboard show/hide) */
|
||||
@@ -313,6 +323,7 @@ const KeyboardHandler = {
|
||||
}
|
||||
|
||||
this.updateLayoutForKeyboard();
|
||||
this._deferViewportSettle();
|
||||
this.lastViewportHeight = currentHeight;
|
||||
},
|
||||
|
||||
@@ -414,32 +425,9 @@ const KeyboardHandler = {
|
||||
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
||||
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
||||
// old (larger) row count when the user types, causing content to scroll
|
||||
// off the visible area with each keystroke.
|
||||
// Note: the throttledResize handler still suppresses ongoing resize events
|
||||
// while keyboard is up — this one-shot resize on open/close is sufficient.
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon)
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
// Eliminate terminal row quantization gap: xterm can only show whole
|
||||
// rows, so leftover pixels create dead space below the last row.
|
||||
// Shrink .main's paddingBottom by the gap so the terminal fills flush
|
||||
// to the accessory bar.
|
||||
this._shrinkPaddingToFit();
|
||||
app.terminal.scrollToBottom();
|
||||
app._syncMobileHelperTextareaToCursor?.();
|
||||
app._localEchoOverlay?.rerender?.();
|
||||
// Send resize to server so PTY dimensions match xterm
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
// Reset again after fit/resize in case layout changes triggered scroll
|
||||
window.scrollTo(0, 0);
|
||||
}, 150);
|
||||
// visualViewport emits multiple heights throughout the OS animation.
|
||||
// Re-schedule on every event and fit only after the final height settles.
|
||||
this._scheduleViewportSettle({ scrollToBottom: true });
|
||||
|
||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||
@@ -454,22 +442,59 @@ const KeyboardHandler = {
|
||||
|
||||
this.resetLayout();
|
||||
|
||||
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.fitAddon) {
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
if (app.terminal) app.terminal.scrollToBottom();
|
||||
// Send resize to server to restore full terminal size
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
}, 100);
|
||||
this._scheduleViewportSettle({ scrollToBottom: true });
|
||||
|
||||
// Reposition subagent windows to stack from top (below header)
|
||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||
},
|
||||
|
||||
/**
|
||||
* Coalesce the keyboard animation into one final xterm reflow and PTY resize.
|
||||
* Only a real show/hide transition arms the settle work; ongoing viewport
|
||||
* resize events merely push a pending settle back (_deferViewportSettle).
|
||||
* A viewport change that never crosses the show/hide thresholds must not
|
||||
* refit: keyboard detection can miss a fine-grained OS animation entirely
|
||||
* (each step under 150px, with the baseline chasing the animation), and the
|
||||
* container is then mid-animation with no keyboard CSS compensation, so a
|
||||
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
|
||||
* garbles the transcript.
|
||||
*/
|
||||
_scheduleViewportSettle({ scrollToBottom = false } = {}) {
|
||||
this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
|
||||
this._settlePending = true;
|
||||
this._armViewportSettleTimer();
|
||||
},
|
||||
|
||||
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
|
||||
_deferViewportSettle() {
|
||||
if (!this._settlePending) return;
|
||||
this._armViewportSettleTimer();
|
||||
},
|
||||
|
||||
_armViewportSettleTimer() {
|
||||
if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer);
|
||||
this._viewportSettleTimer = setTimeout(() => {
|
||||
this._viewportSettleTimer = null;
|
||||
this._settlePending = false;
|
||||
const shouldScrollToBottom = this._settleScrollToBottom;
|
||||
this._settleScrollToBottom = false;
|
||||
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon) {
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
if (this.keyboardVisible) this._shrinkPaddingToFit();
|
||||
if (shouldScrollToBottom) app.terminal.scrollToBottom();
|
||||
app._syncMobileHelperTextareaToCursor?.();
|
||||
app._localEchoOverlay?.rerender?.();
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}, this.VIEWPORT_SETTLE_MS);
|
||||
},
|
||||
|
||||
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
|
||||
_sendTerminalResize() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
||||
|
||||
@@ -363,6 +363,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
||||
@@ -384,6 +385,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyCodexSettingsVisibility();
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
@@ -1542,6 +1544,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
skin: document.getElementById('appSettingsSkin').value,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -1551,6 +1554,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
|
||||
@@ -1726,6 +1730,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
showTabDetachButton: _tdb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
...serverSettings
|
||||
@@ -1999,6 +2004,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
|
||||
// Tab pop-out (open-in-new-window) button: opt-in (App Settings → Tab Bar,
|
||||
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
|
||||
// .tab-detach without it (a tab that is already detached keeps its icon as
|
||||
// the re-focus affordance for the popped-out window).
|
||||
const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
|
||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
|
||||
@@ -2355,6 +2367,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
|
||||
+30
-12
@@ -1383,15 +1383,6 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-tab .tab-prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-tab .tab-suffix {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||
.session-tab .tab-folder {
|
||||
font-size: 0.6rem;
|
||||
@@ -1421,7 +1412,11 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
|
||||
}
|
||||
|
||||
.session-tab:hover .tab-close {
|
||||
/* Icons expand on the ACTIVE tab only (in flow): selection is a deliberate
|
||||
click, so the width change never happens while aiming at a tab, background
|
||||
tabs keep their full title on hover, and a stray click can only switch.
|
||||
Middle-click closes any tab (_setupTabMiddleClickClose in app.js). */
|
||||
.session-tab.active .tab-close {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
padding: 0.15rem 0.35rem;
|
||||
@@ -1983,7 +1978,7 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
transition: opacity 0.15s, width 0.15s, padding 0.15s, transform 0.2s;
|
||||
}
|
||||
|
||||
.session-tab:hover .tab-gear {
|
||||
.session-tab.active .tab-gear {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
padding: 0 0.3rem;
|
||||
@@ -2008,7 +2003,7 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
overflow: hidden;
|
||||
transition: opacity 0.15s, width 0.15s, padding 0.15s;
|
||||
}
|
||||
.session-tab:hover .tab-detach {
|
||||
.session-tab.active .tab-detach {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
padding: 0 0.3rem;
|
||||
@@ -2047,6 +2042,29 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* ===== Tab action icons: active tab only =================================
|
||||
All three per-tab icons live in a .tab-actions wrapper (in flow; it adds
|
||||
no width of its own while the children keep the width:0 collapse above).
|
||||
They expand only on the ACTIVE tab: selection is a deliberate click, so
|
||||
the tab-strip geometry never shifts while the pointer is aiming, hovering
|
||||
a background tab changes nothing (full title stays readable), and a stray
|
||||
click can only switch sessions. Middle-click closes any tab. The phone
|
||||
layout in mobile.css follows the same active-only pattern with its own
|
||||
sizing; the tablet touch fallback there keeps icons always visible. */
|
||||
|
||||
.session-tab .tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
||||
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
||||
A tab that is ALREADY detached keeps its icon regardless: it is the
|
||||
re-focus affordance for the popped-out window. */
|
||||
html:not(.tabs-show-detach) .session-tab:not(.detached) .tab-detach {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Solo (detached single-session) window chrome ===================== */
|
||||
body.solo-mode .session-tabs,
|
||||
body.solo-mode .header-system-stats,
|
||||
|
||||
@@ -40,6 +40,7 @@ const APP_SHELL = [
|
||||
'/vendor/xterm-addon-fit.min.js',
|
||||
'/vendor/xterm-addon-unicode11.min.js',
|
||||
'/vendor/xterm-zerolag-input.js',
|
||||
'/vendor/xterm-predictive-echo.js',
|
||||
'/vendor/xterm.css',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
|
||||
+430
-46
@@ -23,6 +23,80 @@
|
||||
// short window, only the app's synthetic tap-to-position mouse event should
|
||||
// reach xterm.
|
||||
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
||||
// Escape sequences occupy no terminal cells, so they must come out before a
|
||||
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
|
||||
// CSI, charset designators and the short escapes tmux emits; deliberately
|
||||
// approximate — this feeds a size comparison, not a renderer.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const REPLAY_ESCAPE_RE =
|
||||
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
|
||||
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
|
||||
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
|
||||
// (_maybePageCliTranscript).
|
||||
const KEY_PAGE_UP = '\x1b[5~';
|
||||
const KEY_PAGE_DOWN = '\x1b[6~';
|
||||
// Wheel/touch travel (in lines) that adds up to one PageUp/PageDown. Half a
|
||||
// screen rather than a full one: the page key always jumps a whole screen, so
|
||||
// a 1:1 mapping made the fallback feel unreachably slow with a discrete mouse
|
||||
// wheel (Firefox reports 3 lines a notch → 12 notches per page). Overshooting
|
||||
// the finger is the right trade against a gesture that otherwise does nothing.
|
||||
const PAGE_KEY_SCREEN_FRACTION = 0.5;
|
||||
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
|
||||
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
||||
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
|
||||
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
|
||||
// H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional
|
||||
// modifier). Deliberately EXCLUDES terminal query responses that also
|
||||
// arrive via onData (DA `\x1b[?1;2c`, CPR `\x1b[12;34R`) and function
|
||||
// keys, so only genuine cursor/editing keys trigger the local-echo flush.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const COMPOSER_NAV_KEY_PATTERN = /^\x1b(?:\[(?:[ABCDHF]|1;[2-8][ABCDHF]|[1-8](?:;[2-8])?~)|O[ABCDHF])$/;
|
||||
// Prefix xterm.js puts on terminal.paste() payloads while the application
|
||||
// has bracketed-paste mode (DECSET 2004) enabled. Codex, Claude Code and
|
||||
// tmux all enable it, so browser pastes arrive as one onData chunk of
|
||||
// `\x1b[200~<text>\x1b[201~`.
|
||||
const BRACKETED_PASTE_START = '\x1b[200~';
|
||||
|
||||
function isComposerNavKey(data) {
|
||||
return COMPOSER_NAV_KEY_PATTERN.test(data);
|
||||
}
|
||||
|
||||
// Codex composer-row signature, measured against codex-cli 0.147.0
|
||||
// (docs/predictive-echo-plan.md): the composer's cursor row starts with
|
||||
// "› " (U+203A + space) when empty (placeholder text), while typing, and
|
||||
// while the slash picker filters. Modal rows ("Press enter to continue")
|
||||
// and wrapped continuation rows (2-space indent) do NOT match — that is
|
||||
// the ghost eliminator: no prediction is ever painted there.
|
||||
const CODEX_COMPOSER_ROW_RE = /^› /;
|
||||
|
||||
// Classify onData for the predictive echo hook. Terminal query responses
|
||||
// never reach this (suppressed earlier in onData); bracketed pastes, nav
|
||||
// keys and mouse reports all start with ESC => 'clear'.
|
||||
function classifyPredictInput(data) {
|
||||
const cps = Array.from(data); // astral-safe
|
||||
if (cps.length === 1) {
|
||||
const cp = cps[0].codePointAt(0);
|
||||
if (cp === 0x7f) return 'backspace';
|
||||
if (cp >= 0x20) return 'char'; // incl. a single astral emoji
|
||||
return 'clear'; // \r \n \t \x03, bare ESC, ...
|
||||
}
|
||||
if (data.charCodeAt(0) === 0x1b) return 'clear'; // ESC seq: nav, paste, mouse SGR
|
||||
if (data.charCodeAt(0) >= 0x20) return 'text'; // multi-char printable (plain paste,
|
||||
return 'clear'; // ZWJ emoji cluster): wire only, no visual
|
||||
}
|
||||
|
||||
// Predictive-echo gate: predict only while the cursor sits on the codex
|
||||
// composer row. cursorY is baseY-relative (xterm API), hence baseY + cursorY.
|
||||
function isCodexComposerRow(terminal) {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalQueryResponse(data) {
|
||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||
@@ -60,8 +134,18 @@
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
isComposerNavKey,
|
||||
classifyPredictInput,
|
||||
isCodexComposerRow,
|
||||
CODEX_COMPOSER_ROW_RE,
|
||||
BRACKETED_PASTE_START,
|
||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||
REPLAY_ESCAPE_RE,
|
||||
KEY_PAGE_UP,
|
||||
KEY_PAGE_DOWN,
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
@@ -296,7 +380,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// WebGL renderer for GPU-accelerated terminal rendering.
|
||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
// but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents
|
||||
// oversized terminal.write() calls that triggered the stalls.
|
||||
// Disable with ?nowebgl URL param if GPU issues return.
|
||||
// Auto-fallback: _initWebGL installs a long-task watchdog that disables
|
||||
@@ -354,6 +438,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
||||
// Predictive write-through echo (codex): separate opt-in bundle
|
||||
// (vendor/xterm-predictive-echo.js); when it is missing or failed to
|
||||
// load, codex falls back to plain PTY echo exactly like 1.12.2.
|
||||
this._predictiveEcho =
|
||||
typeof PredictiveEchoOverlay !== 'undefined' ? new PredictiveEchoOverlay(this.terminal) : null;
|
||||
this._predictiveEcho?.setPredictWhen((terminal) => window.CodemanTerminalInput.isCodexComposerRow(terminal));
|
||||
if (MobileDetection.isTouchDevice()) {
|
||||
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||
@@ -407,8 +497,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.registerFilePathLinkProvider();
|
||||
|
||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||
// wheel reports (codex, and claude 2.1.187+ — see _shouldForwardWheelToApp),
|
||||
// local scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
||||
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
||||
// live and stay clickable — and its select menus no longer capture wheel
|
||||
// as option navigation (verified against 2.1.202: /model menu highlight
|
||||
@@ -452,6 +542,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this._shouldForwardWheelToApp(ev)) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev));
|
||||
return;
|
||||
}
|
||||
@@ -459,6 +550,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// a stream of tiny pixel deltas, and rounding each one to a whole line
|
||||
// (the ±1 fallback) made slow drags scroll faster than the finger.
|
||||
const lines = this._wheelScrollLinesFloat(ev);
|
||||
// …unless there is no local scrollback to scroll, in which case page the
|
||||
// CLI's own transcript instead of doing nothing (_maybePageCliTranscript).
|
||||
if (this._maybePageCliTranscript(ev, lines)) return;
|
||||
this._logScrollRouting('local-scrollback');
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this._smoothScrollBy(lines);
|
||||
},
|
||||
@@ -476,7 +571,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
||||
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
||||
// opt-out setting and the CLI-version gate apply to touch exactly as they
|
||||
// do to the wheel.
|
||||
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
||||
// when that gate is false and there is no local scrollback to scroll
|
||||
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
||||
// complete no-op on a phone.
|
||||
{
|
||||
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
let touchLastX = 0;
|
||||
@@ -498,7 +596,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Flick momentum keeps feeding the CLI's transcript from the last
|
||||
// touch point; the 40ms coalescer batches the per-frame reports.
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
}
|
||||
@@ -566,8 +664,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
this._logScrollRouting('local-scrollback');
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
@@ -832,11 +932,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this._lastTerminalData = { data, time: performance.now() };
|
||||
|
||||
// ── Local Echo Pass-through ──
|
||||
// After a composer nav key (arrow/Home/End/Delete) the real cursor may
|
||||
// sit mid-text, where the overlay's append-only buffering would corrupt
|
||||
// both the preview and the submitted text. Such sessions are handed
|
||||
// back to plain PTY echo until Enter or Ctrl+C submits/cancels the
|
||||
// composer line (see the nav-key branch below).
|
||||
const echoPassthrough =
|
||||
this._localEchoEnabled && this._echoPassthroughSessions?.has(this.activeSessionId);
|
||||
if (echoPassthrough && (data === '\r' || data === '\x03')) {
|
||||
this._echoPassthroughSessions.delete(this.activeSessionId);
|
||||
}
|
||||
|
||||
// ── Local Echo Mode ──
|
||||
// When enabled, keystrokes are buffered locally in the overlay for
|
||||
// instant visual feedback. Nothing is sent to the PTY until Enter
|
||||
// (or a control char) is pressed — avoids out-of-order char delivery.
|
||||
if (this._localEchoEnabled) {
|
||||
if (this._localEchoEnabled && !echoPassthrough) {
|
||||
if (data === '\x7f') {
|
||||
const source = this._localEchoOverlay?.removeChar();
|
||||
if (source === 'flushed') {
|
||||
@@ -853,9 +965,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this._pendingInput += data;
|
||||
flushInput();
|
||||
} else if (source === false) {
|
||||
// Nothing pending, nothing flushed, nothing detected. The
|
||||
// composer may still hold text the overlay cannot see (buffer
|
||||
// detection is suppressed after a control-char flush), so
|
||||
// forward the backspace instead of swallowing it (issue #218);
|
||||
// an empty composer ignores it.
|
||||
this._pendingInput += data;
|
||||
flushInput();
|
||||
}
|
||||
// 'pending' = removed unsent text (no PTY backspace needed)
|
||||
// false = nothing to remove (swallow the backspace)
|
||||
return;
|
||||
}
|
||||
if (/^[\r\n]+$/.test(data)) {
|
||||
@@ -897,6 +1016,41 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Single-byte ESC (user pressing Escape) still falls through to
|
||||
// the control char handler below.
|
||||
if (data.length > 1 && data.charCodeAt(0) === 27) {
|
||||
// Bracketed paste (terminal.paste() while DECSET 2004 is on):
|
||||
// flush typed-but-unsent overlay text FIRST so the pasted block
|
||||
// lands after it in the composer, not before it (issue #219).
|
||||
// The paste sequence gets its own delayed write: Codex's
|
||||
// paste-burst handling drops keystrokes that arrive in the SAME
|
||||
// PTY read as a bracketed paste (verified against codex 0.147),
|
||||
// mirroring the delayed \r in the Enter branch above.
|
||||
if (data.startsWith(window.CodemanTerminalInput.BRACKETED_PASTE_START)) {
|
||||
const hadPending = !!this._localEchoOverlay?.pendingText;
|
||||
this._flushLocalEchoPending();
|
||||
if (hadPending) {
|
||||
flushInput();
|
||||
setTimeout(() => {
|
||||
this._pendingInput += data;
|
||||
flushInput();
|
||||
}, 80);
|
||||
} else {
|
||||
this._pendingInput += data;
|
||||
flushInput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Composer nav keys (arrows, Home/End, Delete, PgUp/PgDn):
|
||||
// flush unsent text so the key edits the real composer state,
|
||||
// then hand the session to plain PTY echo until Enter/Ctrl+C.
|
||||
// The cursor may now sit mid-text, where append-only buffering
|
||||
// cannot track edits (issue #218).
|
||||
if (window.CodemanTerminalInput.isComposerNavKey(data)) {
|
||||
this._flushLocalEchoPending();
|
||||
if (!this._echoPassthroughSessions) this._echoPassthroughSessions = new Set();
|
||||
this._echoPassthroughSessions.add(this.activeSessionId);
|
||||
this._pendingInput += data;
|
||||
flushInput();
|
||||
return;
|
||||
}
|
||||
// Multi-byte escape sequence — forward to PTY without clearing
|
||||
// overlay/flushed state (terminal response, not user input)
|
||||
this._pendingInput += data;
|
||||
@@ -999,6 +1153,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Predictive Echo (codex): visual only. A plain statement, never a
|
||||
// `return`: control ALWAYS falls through into the send path below,
|
||||
// which is the byte-identity guarantee for #218/#219/#220/#222 —
|
||||
// with the predictor active, absent or throwing, the wire sees the
|
||||
// same bytes. Body in _predictHookOnData (vm-testable).
|
||||
this._predictHookOnData(data);
|
||||
|
||||
// ── Normal Mode (echo disabled) ──
|
||||
this._pendingInput += data;
|
||||
|
||||
@@ -2079,6 +2240,54 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* Rows a `?full=1` capture will occupy once written into xterm.
|
||||
*
|
||||
* tmux joins wrapped rows in that capture (`capture-pane -J`), so a long
|
||||
* logical line re-wraps into several xterm rows on write and a bare newline
|
||||
* count would undershoot; escape sequences occupy no cells and come out
|
||||
* first. Approximate by construction (it ignores double-width glyphs), which
|
||||
* is fine: the only consumer is a coarse size comparison
|
||||
* (_replayWouldShrinkBuffer), and it runs once per cooldown-guarded re-pull.
|
||||
*/
|
||||
_estimateReplayRows(text, cols) {
|
||||
if (typeof text !== 'string' || !text) return 0;
|
||||
const width = cols > 0 ? cols : 80;
|
||||
const plain = text.replace(window.CodemanTerminalInput.REPLAY_ESCAPE_RE, '');
|
||||
let rows = 0;
|
||||
for (const line of plain.split('\n')) {
|
||||
const cells = line.endsWith('\r') ? line.length - 1 : line.length;
|
||||
rows += cells > width ? Math.ceil(cells / width) : 1;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* DOWNGRADE GUARD for the scroll-to-top re-pull (issue #205, round 2).
|
||||
*
|
||||
* `_maybeRefetchFullHistory` resets the terminal and rewrites it from the
|
||||
* capture, which is a straight win when tmux holds more than the browser —
|
||||
* the burst-repaint and tab-switch losses it was built for. But a repaint-mode
|
||||
* CLI pane keeps NO tmux history of its own (`history_size≈0` measured for a
|
||||
* Claude pane), so there the capture is roughly ONE frame while xterm may hold
|
||||
* hundreds of rows of replayed frames. Rewriting then DESTROYS history
|
||||
* mid-scroll: exactly the "goes back a limited amount, repeats blocks, gets
|
||||
* worse when I reach the top" report from the 1.12.0 retest.
|
||||
*
|
||||
* So refuse when the capture is smaller, with a one-screen tolerance because
|
||||
* both sides are estimates: `buffer.active.length` includes the blank rows
|
||||
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
||||
* Only a capture that is worse by more than a full screen counts as a
|
||||
* downgrade, which leaves every genuine recovery case untouched.
|
||||
*/
|
||||
_replayWouldShrinkBuffer(capture) {
|
||||
const term = this.terminal;
|
||||
const rowsNow = term?.buffer?.active?.length || 0;
|
||||
if (!rowsNow) return false;
|
||||
const screen = term?.rows || 24;
|
||||
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ease-out smooth scrolling for the local wheel path. The capture-phase
|
||||
* wheel handler owns local scrolling (xterm's own smooth scroller is
|
||||
@@ -2192,17 +2401,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Accumulate raw data (may contain DEC 2026 markers)
|
||||
this.pendingWrites.push(data);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||
// content between 2026h/2026l and renders atomically. No need for
|
||||
// client-side incomplete-block detection; just flush every frame.
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Schedule one render-budgeted terminal flush.
|
||||
*
|
||||
* Clear the scheduled flag before flushing so flushPendingWrites() can queue
|
||||
* another yield when a large final batch leaves bytes behind. Keeping the
|
||||
* flag set through the flush stranded that remainder until unrelated output
|
||||
* arrived, which looked like truncated responses and idle shell commands.
|
||||
*/
|
||||
_scheduleTerminalWriteFlush() {
|
||||
if (this.writeFrameScheduled || this.pendingWrites.length === 0) return;
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
this.writeFrameScheduled = false;
|
||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||
// content between 2026h/2026l and renders atomically.
|
||||
this.flushPendingWrites();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2218,13 +2436,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.flickerFilterActive = false;
|
||||
|
||||
// Trigger a normal flush
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
}
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
/**
|
||||
* Flush the local-echo overlay's unsent text into `_pendingInput` (no
|
||||
* trailing Enter) and reset overlay + flushed-state tracking. Used before
|
||||
* forwarding sequences that must arrive AFTER the typed text (bracketed
|
||||
* paste, composer nav keys). The caller forwards its own sequence: nav keys
|
||||
* ride the same write, pastes get a delayed second write because codex
|
||||
* drops keys that share a PTY read with a bracketed paste.
|
||||
*/
|
||||
_flushLocalEchoPending() {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
this._localEchoOverlay?.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (text) this._pendingInput += text;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2266,9 +2495,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
} else if (session.mode === 'shell' || session.mode === 'codex') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
// Codex mode: the composer is fully interactive per keystroke. Typing
|
||||
// "/" pops a live-filtering command picker (issue #222), the composer
|
||||
// grows and rewraps as it fills (#220), pastes are bracketed (#219)
|
||||
// and arrows/history edit server-side state (#218). Buffering
|
||||
// keystrokes until Enter starves all of that, so codex sessions use
|
||||
// plain PTY echo like shell — visually augmented by the predictive
|
||||
// write-through echo (see _localEchoPolicy below and the onData hook).
|
||||
// Disable the buffer overlay by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
@@ -2301,6 +2537,39 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Per-session echo policy: 'buffer' (overlay), 'predict' (codex
|
||||
// write-through, see the onData predict hook), 'off'. _localEchoEnabled
|
||||
// keeps its exact historical values above (false for codex/shell), so
|
||||
// every existing consumer is unchanged; this field is purely additive.
|
||||
let policy = 'off';
|
||||
if (session && echoEnabled) {
|
||||
if (session.mode === 'codex') policy = 'predict';
|
||||
else if (session.mode !== 'shell') policy = 'buffer';
|
||||
}
|
||||
this._localEchoPolicy = policy;
|
||||
if (policy !== 'predict') this._predictiveEcho?.clearPredictions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Predictive-echo onData hook (codex write-through). VISUAL ONLY: paints,
|
||||
* pops or clears prediction spans and never touches _pendingInput, never
|
||||
* sends, never throws into the caller. The onData wire path behaves
|
||||
* byte-identically with this active, absent or broken.
|
||||
*/
|
||||
_predictHookOnData(data) {
|
||||
if (this._localEchoPolicy !== 'predict' || !this._predictiveEcho) return;
|
||||
try {
|
||||
const kind = window.CodemanTerminalInput.classifyPredictInput(data);
|
||||
if (kind === 'char') this._predictiveEcho.predictChar(data);
|
||||
else if (kind === 'backspace') this._predictiveEcho.predictBackspace();
|
||||
// 'clear' AND 'text' (plain paste, IME word commits) both change the
|
||||
// composer in ways the display has not shown yet: clear the run and let
|
||||
// the addon's anchor hold suppress prediction until the echo catches up
|
||||
else this._predictiveEcho.clearPredictions();
|
||||
} catch {
|
||||
/* predictions must never block the wire */
|
||||
}
|
||||
},
|
||||
|
||||
// CJK textarea already provides visual feedback — bypass local echo
|
||||
@@ -2310,6 +2579,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
||||
return;
|
||||
}
|
||||
// Bypasses onData (like insertTerminalText): predictions cannot see this
|
||||
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
},
|
||||
@@ -2350,13 +2621,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
|
||||
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
|
||||
deferred = true;
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
}
|
||||
this._scheduleTerminalWriteFlush();
|
||||
}
|
||||
if (
|
||||
preserveViewportY !== null &&
|
||||
@@ -2671,7 +2936,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Insert editable text at the active prompt without pressing Enter. */
|
||||
insertTerminalText(text) {
|
||||
if (!this.activeSessionId || !text) return;
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
// Under predict the text goes out via sendInput (bypasses onData), so the
|
||||
// hook never sees it: clear outstanding predictions here instead.
|
||||
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||
if (
|
||||
this._localEchoEnabled &&
|
||||
this._localEchoOverlay &&
|
||||
!this._echoPassthroughSessions?.has(this.activeSessionId)
|
||||
) {
|
||||
this._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
this.sendInput(text).catch(() => {});
|
||||
@@ -2692,6 +2964,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._inputFlushTimeout = null;
|
||||
}
|
||||
this._pendingInput = '';
|
||||
// Composer content is about to change out from under any predictions
|
||||
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||
@@ -2926,11 +3200,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||
// scroll its transcript on SGR wheel reports: codex, or claude 2.1.187+
|
||||
// (older Claude Code captures wheel as select-menu option navigation; an
|
||||
// unknown version is treated as older). Gemini is a strip mode too but its
|
||||
// wheel behavior is unverified, so it keeps the local wheel — taps/clicks
|
||||
// are still forwarded for it (harmless no-ops at worst).
|
||||
// scroll its transcript on SGR wheel reports — which today is claude 2.1.187+
|
||||
// and nothing else (older Claude Code captures wheel as select-menu option
|
||||
// navigation; an unknown version is treated as older). Gemini and codex are
|
||||
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
||||
// for them (harmless no-ops at worst).
|
||||
//
|
||||
// Codex USED to forward here and was the #227 regression (DodgyBadger, Codex
|
||||
// latest / Chrome / Win11: dead wheel in codex, working scrollbar drag).
|
||||
// Measured on 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 — it runs an inline viewport (`alternate_on=0`) and pushes
|
||||
// its transcript into the terminal's own scrollback (tmux `history_size`
|
||||
// grows), so there is no in-app pager to drive and local scrollback IS the
|
||||
// codex transcript. Forwarding therefore swallowed every tick.
|
||||
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
|
||||
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
|
||||
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
|
||||
@@ -2969,16 +3252,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
|
||||
// it over forwarding the wheel to the CLI's transcript (issue #154). Cheap —
|
||||
// loadAppSettingsFromStorage() is cache-backed.
|
||||
//
|
||||
// FOOTGUN, and why it is handled downstream rather than here: for a
|
||||
// repaint-mode CLI that local scrollback is EMPTY (tmux keeps no history for
|
||||
// the pane), so this setting can silently convert a working wheel into a
|
||||
// dead one — a plausible reading of the #205 retest, where a user whose
|
||||
// scrolling was broken on 1.11.x may well have flipped it while hunting for
|
||||
// a fix. Scoping the setting away from those modes would be the other
|
||||
// option, but it would override an explicit user choice; instead the caller
|
||||
// falls through to _maybePageCliTranscript, so the gesture still pages the
|
||||
// CLI's transcript and the setting keeps meaning exactly what it says.
|
||||
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
|
||||
const mode = this.terminal?.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode === 'claude') {
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
} else if (sessionMode !== 'codex') {
|
||||
return false;
|
||||
}
|
||||
if (sessionMode !== 'claude') return false;
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so
|
||||
// that leaving the bottom handed the wheel back to local scrollback and both
|
||||
// histories stayed reachable without a mode switch. In practice that inverted
|
||||
@@ -3012,13 +3302,105 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!pos) return;
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), 5);
|
||||
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
|
||||
},
|
||||
|
||||
/**
|
||||
* Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR
|
||||
* wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a
|
||||
* tmux send-keys server-side, so per-event writes would spawn a process storm
|
||||
* on a single flick; the queue is bounded so a wild scroll can't build a
|
||||
* backlog that keeps scrolling after the finger stops.
|
||||
*/
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || !this.activeSessionId) return;
|
||||
const queued = this._wheelSgrQueue || '';
|
||||
if (queued.length > 512) return;
|
||||
this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
this._wheelSgrQueue = queued + data;
|
||||
if (this._wheelSgrFlushTimer) return;
|
||||
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
|
||||
},
|
||||
|
||||
/**
|
||||
* True when this session's LOCAL scrollback is structurally empty: a Claude
|
||||
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
|
||||
* overwrites the last, so xterm's normal buffer never grows past one screen
|
||||
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
|
||||
* is routed — the "wheel does nothing at all" half of the #205 retest.
|
||||
*/
|
||||
_localScrollbackIsHollow() {
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||
if (mode !== 'claude') return false;
|
||||
const buf = this.terminal?.buffer?.active;
|
||||
if (!buf || buf.type === 'alternate') return false;
|
||||
return (buf.baseY || 0) === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* LAST-RESORT scroll for a hollow local buffer: translate gesture lines into
|
||||
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
|
||||
*
|
||||
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
||||
* on a Claude session that has no local history to fall back on: the CLI
|
||||
* version probe failed or is genuinely older than 2.1.187, or the user turned
|
||||
* on "Wheel scrolls local history" (which pins the wheel to a buffer that,
|
||||
* for a repaint-mode CLI, is empty — the setting's footgun). Before this, all
|
||||
* of those produced a completely dead gesture; the #205 reporter proved the
|
||||
* keyboard route works by paging back through intact text with Fn+Up.
|
||||
*
|
||||
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
|
||||
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
||||
* the explicit "give me local scrollback" gesture and must keep that meaning.
|
||||
*
|
||||
* @returns true when the gesture was consumed here (the caller must not also
|
||||
* scroll locally).
|
||||
*/
|
||||
_maybePageCliTranscript(ev, lines) {
|
||||
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
|
||||
if (!this._localScrollbackIsHollow()) return false;
|
||||
// Leftover travel belongs to the tab it was made on.
|
||||
if (this._pageKeySession !== this.activeSessionId) {
|
||||
this._pageKeySession = this.activeSessionId;
|
||||
this._pageKeyPending = 0;
|
||||
}
|
||||
const tuning = window.CodemanTerminalInput;
|
||||
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
|
||||
const pending = (this._pageKeyPending || 0) + lines;
|
||||
const pages = Math.trunc(pending / perPage);
|
||||
this._pageKeyPending = pending - pages * perPage;
|
||||
if (pages) {
|
||||
const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN;
|
||||
this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH)));
|
||||
}
|
||||
this._logScrollRouting('page-keys');
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* One line in the console saying WHY a scroll gesture went where it went.
|
||||
*
|
||||
* Issue #205 ran two rounds of remote guesswork — is the CLI version probe
|
||||
* empty, is the opt-out setting on, did a mouse DECSET leak past the strip? —
|
||||
* that this single log answers directly. Logged once per session per distinct
|
||||
* decision, so a steady gesture stays silent and a CHANGE (e.g. the version
|
||||
* arriving late and flipping the route) still prints.
|
||||
*/
|
||||
_logScrollRouting(decision) {
|
||||
const sessionId = this.activeSessionId || '(none)';
|
||||
const session = this.sessions?.get(sessionId);
|
||||
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
||||
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
||||
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
||||
const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`;
|
||||
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
||||
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
||||
this._scrollRoutingLogged.set(sessionId, signature);
|
||||
console.log(
|
||||
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})`
|
||||
);
|
||||
},
|
||||
|
||||
_flushWheelSgrQueue() {
|
||||
this._wheelSgrFlushTimer = null;
|
||||
const data = this._wheelSgrQueue;
|
||||
@@ -3090,6 +3472,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
localStorage.setItem('codeman-font-size', size);
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -3224,6 +3607,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Refresh it on live skin changes so typed text never keeps the prior
|
||||
// theme's dark backing surface or foreground color.
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
try {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
} catch {}
|
||||
|
||||
@@ -612,6 +612,9 @@ const VoiceInput = {
|
||||
if (text) app.sendInput(text).catch(() => {});
|
||||
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
||||
} else {
|
||||
// Predict-mode sessions (codex) take this branch: the send bypasses
|
||||
// onData, so clear outstanding predictions here (composer will reset)
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
app.sendInput('\r').catch(() => {});
|
||||
}
|
||||
// Blink then restore
|
||||
|
||||
@@ -97,8 +97,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="tab-name">${escapeHtml(webview.name)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span>
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span></span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
@@ -397,9 +396,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
out.textContent = 'Test failed (invalid URL?).';
|
||||
return;
|
||||
}
|
||||
// #238: the probe runs server-to-upstream; say so, or a passing Test reads as
|
||||
// "the embedded page will work" when the browser sandbox / a cookie-auth
|
||||
// reverse proxy in front of Codeman can still break it.
|
||||
out.textContent = probe.reachable
|
||||
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
|
||||
: `Not reachable. ${probe.reason}`;
|
||||
? `Reachable (HTTP ${probe.status}) from the Codeman server. ${probe.reason} ` +
|
||||
`(Tests server-to-upstream reachability only, not how the page behaves in a sandboxed frame.)`
|
||||
: `Not reachable from the Codeman server. ${probe.reason}`;
|
||||
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
||||
},
|
||||
|
||||
|
||||
@@ -640,6 +640,25 @@ async function buildExternalAttachmentRouteItem(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers Fastify already put on the reply, in a shape `writeHead` accepts.
|
||||
*
|
||||
* `reply.raw.writeHead()` writes straight to the Node response and bypasses
|
||||
* Fastify's header store, so anything the security `onRequest` hook granted — CORS
|
||||
* for localhost origins, nosniff, frame-options, CSP — is silently dropped on every
|
||||
* route that answers this way. Spread this first and let the route's own headers
|
||||
* win over it.
|
||||
*/
|
||||
function inheritedHeaders(reply: {
|
||||
getHeaders(): NodeJS.Dict<number | string | string[]>;
|
||||
}): Record<string, number | string | string[]> {
|
||||
const out: Record<string, number | string | string[]> = {};
|
||||
for (const [name, value] of Object.entries(reply.getHeaders())) {
|
||||
if (value !== undefined) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||
@@ -1337,6 +1356,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
if (download === 'true' || ext === 'svg') {
|
||||
reply.raw.writeHead(200, {
|
||||
...inheritedHeaders(reply),
|
||||
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
@@ -1576,6 +1596,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
// Set up SSE headers
|
||||
reply.raw.writeHead(200, {
|
||||
...inheritedHeaders(reply),
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
@@ -1709,6 +1730,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
// Bypass Fastify compression — write directly to raw response
|
||||
reply.raw.writeHead(200, {
|
||||
...inheritedHeaders(reply),
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': content.length,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
@@ -22,6 +23,27 @@ export function registerHookEventRoutes(
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
|
||||
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
|
||||
// and can flap mid-turn), so these two are what an orchestrating agent should
|
||||
// wait on.
|
||||
//
|
||||
// Gated on the session's MODE, matching `resolveWaitSignals` on the read side.
|
||||
// Without it the guard is one-sided: a caller cannot ASK for `stop` on a shell or
|
||||
// codex session, but this endpoint would happily deliver one for it. Hook events
|
||||
// carry no identity beyond a per-instance secret shared by every case, so this is
|
||||
// also the cheap half of the forgery surface — a `stop` claimed for a session that
|
||||
// could never legitimately emit one is now dropped instead of steering another
|
||||
// agent's control flow.
|
||||
const waitSession = ctx.sessions.get(sessionId);
|
||||
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
|
||||
if (event === 'stop') {
|
||||
sessionWaits.notifySignal(sessionId, 'stop');
|
||||
} else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
|
||||
sessionWaits.notifySignal(sessionId, 'blocked');
|
||||
}
|
||||
}
|
||||
|
||||
// Signal the respawn controller based on hook event type
|
||||
const controller = ctx.respawnControllers.get(sessionId);
|
||||
if (controller) {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { join, dirname, extname, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
type SessionStatus,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
@@ -40,8 +42,19 @@ import {
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
SessionOrderUpdateSchema,
|
||||
SessionWaitQuerySchema,
|
||||
SessionWaitOutputQuerySchema,
|
||||
} from '../schemas.js';
|
||||
import { mergeSessionOrder } from '../../session-order.js';
|
||||
import {
|
||||
sessionWaits,
|
||||
resolveWaitSignals,
|
||||
signalForStatus,
|
||||
WaitCapacityError,
|
||||
type WaitSignal,
|
||||
type SignalWaitResult,
|
||||
} from '../session-wait-registry.js';
|
||||
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
@@ -66,6 +79,7 @@ import {
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
applyStatusLineConfig,
|
||||
applyAgentSkill,
|
||||
refreshStaleCodemanHooks,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -314,6 +328,265 @@ async function clampExternalCliBypassForOwner(
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Validate a wait query WITHOUT throwing away the Zod issue.
|
||||
*
|
||||
* `parseBody`'s message argument REPLACES the issue text, so `?timeout=30s` came
|
||||
* back as a bare "Invalid wait parameters": the caller could not tell which of
|
||||
* `until`, `timeout` or `fresh` it got wrong, and its only move was to retry with
|
||||
* a different guess. These endpoints are driven by an LLM with no documentation in
|
||||
* context — the error message IS the documentation, which is why the signal parser
|
||||
* one line later goes to the trouble of naming the bad token and listing the valid
|
||||
* ones. This keeps the endpoint label AND names the offending field.
|
||||
*/
|
||||
function parseWaitQuery<T>(schema: z.ZodType<T>, query: unknown, label: string): T {
|
||||
const result = schema.safeParse(query);
|
||||
if (result.success) return result.data;
|
||||
const issue = result.error.issues[0];
|
||||
const field = issue && issue.path.length > 0 ? issue.path.join('.') : '';
|
||||
const detail = issue?.message ?? 'validation failed';
|
||||
const message = field ? `Invalid ${label} parameter '${field}': ${detail}` : `Invalid ${label} parameters: ${detail}`;
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, message),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a waiter-cap rejection to the code that tells the caller the truth.
|
||||
*
|
||||
* The two caps mean different things and warrant different recovery: `session` is
|
||||
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
|
||||
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
|
||||
* documented as "Session is busy") sent an agent off to a different session to hit
|
||||
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
|
||||
* later", and clients and proxies already treat 429 that way.
|
||||
*/
|
||||
function waitCapacityResponse(err: WaitCapacityError): ApiResponse<never> {
|
||||
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
|
||||
// The registry's message already names the scope and the limit; passing it through
|
||||
// verbatim keeps the wording in one place.
|
||||
return createErrorResponse(code, err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signal a session is ALREADY emitting, corrected for liveness.
|
||||
*
|
||||
* `signalForStatus` alone is not enough here, because `Session` parks a DEAD PTY at
|
||||
* `_status = 'idle'` (both `onExit` handlers do) and the object survives in the
|
||||
* session map until an explicit DELETE. Trusting the status therefore answers the
|
||||
* default wait with `{signal:"idle", immediate:true}` for a worker that has
|
||||
* crashed — HTTP 200, no error anywhere, and the agent types its next prompt into a
|
||||
* corpse — while `until=exit` blocks for the full timeout on an event that already
|
||||
* happened and can never happen again.
|
||||
*
|
||||
* `pid === null` means no process is behind this session: it exited, it was
|
||||
* detached, or it was created and never started. All three are `exit` from a
|
||||
* caller's point of view — nothing is running — and in all three the agent's
|
||||
* correct next move is to (re)start the worker rather than to type at it. The
|
||||
* response still carries the raw `status` alongside, so nothing is hidden.
|
||||
*
|
||||
* ⚠️ `pid` alone is NOT enough, and on the normal configuration it is never the
|
||||
* thing that fires — see `workerIsDead()`. `dead` carries the mux layer's answer.
|
||||
*
|
||||
* Fixing it HERE rather than in `signalForStatus` is deliberate: liveness is not
|
||||
* derivable from `SessionStatus`, and the registry holds no `Session` reference.
|
||||
*/
|
||||
function currentSignalFor(session: { pid: number | null; status: SessionStatus }, dead: boolean): WaitSignal | null {
|
||||
if (dead || session.pid === null || session.pid === undefined) return 'exit';
|
||||
return signalForStatus(session.status);
|
||||
}
|
||||
|
||||
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
|
||||
//
|
||||
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
|
||||
// `remain-on-exit on` for every session it creates, so when the command inside the
|
||||
// pane exits, tmux keeps the pane (`pane_dead=1`), the tmux session survives, the
|
||||
// attach client keeps running and `pid` never goes null — no `exit` event is emitted
|
||||
// and nothing in `Session` changes. Measured on a shell worker killed with `exit 42`:
|
||||
// tmux reports `pane_dead=1 status=42` while Codeman reports `pid=309406 status=idle`
|
||||
// and the DEFAULT wait answers `{signal:"idle", immediate:true}` in 0 ms for a corpse.
|
||||
// So the liveness check has to ask the mux layer. `pid === null` still matters: it is
|
||||
// the right (and only) answer for a direct-PTY session, which has no pane to ask about.
|
||||
//
|
||||
// Cost control, because `isPaneDead()` is a synchronous `execSync` and `/wait` is
|
||||
// polled in a loop by design:
|
||||
// 1. Only mux-backed sessions are probed at all.
|
||||
// 2. Only requests that actually wait probe — a plain `POST .../input` (the browser's
|
||||
// hot path, thousands per session) never touches tmux.
|
||||
// 3. Results are cached per pane for PANE_DEATH_TTL_MS, so a poll loop cannot turn
|
||||
// into one exec per request.
|
||||
// 4. The while-blocked watcher is ONE timer per session no matter how many waiters
|
||||
// are parked on it, and it exists only while at least one of them is.
|
||||
|
||||
/** How long a pane-liveness probe is reused. Long enough to absorb a poll loop. */
|
||||
const PANE_DEATH_TTL_MS = 750;
|
||||
|
||||
/** How often a session with a parked waiter is re-checked for a dead worker. */
|
||||
const PANE_DEATH_POLL_MS = 3_000;
|
||||
|
||||
/** Bounded, because a 24h server churns through panes. */
|
||||
const paneDeathCache = new LRUMap<string, { dead: boolean; at: number }>({ maxSize: 256 });
|
||||
|
||||
/** One watcher per pane, refcounted by the waits currently parked on it. */
|
||||
const paneDeathWatchers = new Map<string, { timer: NodeJS.Timeout; refs: number }>();
|
||||
|
||||
type LivenessSession = { usesMux?: boolean; muxName?: string | null };
|
||||
|
||||
/**
|
||||
* Whether the worker inside this session's tmux pane has exited.
|
||||
*
|
||||
* False for anything not tmux-backed (nothing to ask), and false when the probe is
|
||||
* unavailable or throws — an unknown answer must never invent a death.
|
||||
*/
|
||||
function workerIsDead(mux: InfraPort['mux'], session: LivenessSession, now: number = Date.now()): boolean {
|
||||
const muxName = session.usesMux === false ? null : session.muxName;
|
||||
if (!muxName) return false;
|
||||
// Defensive: `TerminalMultiplexer` declares it, but route-test doubles may not.
|
||||
if (typeof mux?.isPaneDead !== 'function') return false;
|
||||
|
||||
const cached = paneDeathCache.get(muxName);
|
||||
if (cached && now - cached.at < PANE_DEATH_TTL_MS) return cached.dead;
|
||||
|
||||
let dead = false;
|
||||
try {
|
||||
dead = mux.isPaneDead(muxName) === true;
|
||||
} catch {
|
||||
dead = false;
|
||||
}
|
||||
paneDeathCache.set(muxName, { dead, at: now });
|
||||
return dead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every waiter on a session whose worker has died, in the documented order.
|
||||
*
|
||||
* The same pair the PTY-exit listener and the delete path use, for the same reason:
|
||||
* `until=exit` callers get their signal, everyone else gets `ended: true` instead of
|
||||
* burning the rest of their timeout on feeds that will never produce anything.
|
||||
*/
|
||||
function releaseWaitersForDeadWorker(sessionId: string): void {
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* While a wait is parked on a mux-backed session, poll for the worker dying.
|
||||
*
|
||||
* Without this, a worker that dies DURING a wait is invisible: no `exit` event fires
|
||||
* (the attach client is still alive), no output arrives, and the caller blocks for its
|
||||
* full timeout — the common orchestration case, "send a prompt and wait", where the
|
||||
* worker crashes mid-turn.
|
||||
*
|
||||
* @returns a release function; call it in a `finally`, or the timer outlives the wait.
|
||||
*/
|
||||
function watchForDeadWorker(mux: InfraPort['mux'], session: LivenessSession, sessionId: string): () => void {
|
||||
const muxName = session.usesMux === false ? null : session.muxName;
|
||||
if (!muxName || typeof mux?.isPaneDead !== 'function') return () => {};
|
||||
|
||||
const existing = paneDeathWatchers.get(muxName);
|
||||
if (existing) {
|
||||
existing.refs++;
|
||||
} else {
|
||||
const timer = setInterval(() => {
|
||||
if (!workerIsDead(mux, session)) return;
|
||||
releaseWaitersForDeadWorker(sessionId);
|
||||
}, PANE_DEATH_POLL_MS);
|
||||
// Auxiliary to the waiter's own timer, which is deliberately NOT unref'd; this one
|
||||
// must never be the reason the process stays up.
|
||||
timer.unref();
|
||||
paneDeathWatchers.set(muxName, { timer, refs: 1 });
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const entry = paneDeathWatchers.get(muxName);
|
||||
if (!entry) return;
|
||||
entry.refs--;
|
||||
if (entry.refs <= 0) {
|
||||
clearInterval(entry.timer);
|
||||
paneDeathWatchers.delete(muxName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: pane-liveness state is module-level, so a suite must be able to reset it. */
|
||||
export function _resetPaneLivenessState(): void {
|
||||
for (const entry of paneDeathWatchers.values()) clearInterval(entry.timer);
|
||||
paneDeathWatchers.clear();
|
||||
paneDeathCache.clear();
|
||||
}
|
||||
|
||||
/** Test seam: how many panes are currently being watched for a dead worker. */
|
||||
export function _paneDeathWatcherCount(): number {
|
||||
return paneDeathWatchers.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* An `AbortController` that fires when the CLIENT goes away, and only then.
|
||||
*
|
||||
* Freeing an abandoned waiter matters because the documented pattern is a loop of
|
||||
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
|
||||
* every iteration until the cap is hit and an innocent session reports busy. Same for
|
||||
* any proxy that cuts the connection.
|
||||
*
|
||||
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
|
||||
* as soon as the request body has finished streaming, which on a POST is BEFORE the
|
||||
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
|
||||
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
|
||||
* instantly and silently kills the feature (it survives on GET only because a GET has
|
||||
* no body to finish). `reply.raw` emits `'close'` both when the response completes
|
||||
* and when the socket dies, and `writableFinished` is what tells those apart: true
|
||||
* only if the response actually went out. The guard is load-bearing, not defensive.
|
||||
*
|
||||
* `app.inject()` never emits `'close'` at all, so this is only observable over real
|
||||
* HTTP — which is why the regression test for it binds a port.
|
||||
*/
|
||||
function abortOnClientHangUp(reply: FastifyReply): AbortController {
|
||||
const controller = new AbortController();
|
||||
reply.raw.on('close', () => {
|
||||
if (!reply.raw.writableFinished) controller.abort();
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the agent skill into a case on create, surfacing only the REFUSALS.
|
||||
*
|
||||
* `applyAgentSkill` declines two shapes rather than writing through them ('foreign':
|
||||
* an unmarked skills/codeman the user authored; 'symlink': the skill dir or its
|
||||
* parent is a link). Both were silent: the user flips `agentSkillEnabled` on, nothing
|
||||
* appears in the case, and there is nowhere to look for why. The ordinary outcomes
|
||||
* ('installed'/'refreshed'/'unchanged') stay unlogged since they would print on every
|
||||
* single session create.
|
||||
*
|
||||
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
|
||||
* may fail the create.
|
||||
*/
|
||||
async function injectAgentSkill(casePath: string): Promise<void> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
try {
|
||||
const result = await applyAgentSkill(casePath, true);
|
||||
if (result === 'foreign') {
|
||||
console.warn(
|
||||
`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`
|
||||
);
|
||||
} else if (result === 'symlink') {
|
||||
console.warn(
|
||||
`[agent-skill] not injected: ${skillDir} (or its parent) is a symlink, refusing to write through it. Replace it with a real directory to let Codeman install the skill.`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn(`[agent-skill] injection failed for ${skillDir}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
@@ -458,6 +731,13 @@ export function registerSessionRoutes(
|
||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
||||
// .claude rationale as the statusLine above: a create must never remove the
|
||||
// skill from under other live sessions in the repo. Marker-guarded, so a
|
||||
// user's own skills/codeman is never touched.
|
||||
if (await ctx.getAgentSkillEnabled()) {
|
||||
await injectAgentSkill(workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
@@ -863,9 +1143,9 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Send Input ==========
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req) => {
|
||||
app.post('/api/sessions/:id/input', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const { input, useMux, seq, clientId, wait, waitTimeout } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const inputStr = String(input);
|
||||
@@ -876,33 +1156,319 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Send-and-wait (agent orchestration). This has to be ONE endpoint rather than a
|
||||
// POST followed by GET .../wait: between the write and the session flipping to
|
||||
// `working` there is a window in which a separate wait sees the session still
|
||||
// idle and returns instantly, reporting the PREVIOUS turn as this turn's answer.
|
||||
// Registering the waiter before the write closes that window.
|
||||
const wantsWait =
|
||||
wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
|
||||
let until: readonly WaitSignal[] = [];
|
||||
if (wantsWait) {
|
||||
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
|
||||
if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
|
||||
until = resolved.until;
|
||||
}
|
||||
|
||||
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
|
||||
// client's ACK, so a tagged duplicate redelivery must still return 200 but
|
||||
// skip the write. Untagged requests (curl/legacy) always apply.
|
||||
if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
|
||||
const tagged = typeof clientId === 'string' && typeof seq === 'number';
|
||||
const duplicate = tagged && !session.shouldApplyInput(clientId as string, seq as number);
|
||||
if (duplicate && !wantsWait) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
||||
// (thousands of calls per session) must stay exec-free.
|
||||
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
||||
|
||||
const timeoutMs = clampWaitMs(waitTimeout ?? undefined);
|
||||
// Same slot leak as the GET routes: a client that gives up mid-wait would
|
||||
// otherwise hold a waiter for the full timeout. Response-side, always — see
|
||||
// abortOnClientHangUp: on THIS route a request-side listener fires the moment the
|
||||
// JSON body finishes streaming and aborts every send-and-wait before it starts.
|
||||
const abort = abortOnClientHangUp(reply);
|
||||
let waitPromise: Promise<SignalWaitResult> | null = null;
|
||||
if (wantsWait) {
|
||||
try {
|
||||
waitPromise = sessionWaits.waitForSignal(id, {
|
||||
until,
|
||||
timeoutMs,
|
||||
owner: ownerFor(req),
|
||||
abortSignal: abort.signal,
|
||||
// A FRESH delivery must not be satisfied by the state the session is already
|
||||
// in: it is idle right now, which is precisely why we are typing at it.
|
||||
// A DUPLICATE has no new turn coming, so it answers from the current state
|
||||
// instead of blocking for a transition that already happened.
|
||||
requireTransition: !duplicate,
|
||||
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WaitCapacityError) {
|
||||
// Nothing has been written yet, but `shouldApplyInput` already consumed the
|
||||
// seq. Give it back or the caller's retry is rejected as a duplicate and the
|
||||
// input is lost by the very mechanism meant to make delivery reliable.
|
||||
if (tagged && !duplicate) session.forgetInputSeq(clientId as string, seq as number);
|
||||
return waitCapacityResponse(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
|
||||
|
||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||
if (useMux) {
|
||||
// Fire-and-forget: don't block HTTP response on tmux child process.
|
||||
// Fallback to direct write on failure.
|
||||
//
|
||||
// Because the response has already been sent by then, a failure there is the
|
||||
// one case the caller can never learn about — so the dedup bookkeeping is
|
||||
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
|
||||
// very mechanism reliable delivery exists for, is rejected as a duplicate.
|
||||
const undoOnFailure = () => {
|
||||
if (tagged) session.forgetInputSeq(clientId as string, seq as number);
|
||||
};
|
||||
|
||||
// Whether the bytes actually reached a write path. Only meaningful on the wait
|
||||
// path (the fire-and-forget branches return before the response is built), and
|
||||
// reported there instead of the old `!duplicate`: a PTY that has exited fails
|
||||
// BOTH writes, and telling the caller "delivered, but it timed out" points it at
|
||||
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
||||
let delivered = false;
|
||||
|
||||
if (duplicate) {
|
||||
// Redelivery of an already-applied input: skip the write, but still honor the
|
||||
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
||||
} else if (useMux && waitPromise) {
|
||||
// The response is already staying open for the wait, so the tmux write can be
|
||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
||||
if (ok) {
|
||||
delivered = true;
|
||||
} else {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
delivered = session.write(inputStr);
|
||||
if (!delivered) undoOnFailure();
|
||||
}
|
||||
} else if (useMux) {
|
||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||
session
|
||||
.writeViaMux(inputStr)
|
||||
.then((ok) => {
|
||||
if (!ok) {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
session.write(inputStr);
|
||||
}
|
||||
if (ok) return;
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
})
|
||||
.catch(() => {
|
||||
session.write(inputStr);
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
});
|
||||
} else {
|
||||
session.write(inputStr);
|
||||
// Same rollback. NOT an error response, deliberately: a session can
|
||||
// legitimately have no PTY yet (created but not started), and callers have
|
||||
// always been able to write to one without a 4xx.
|
||||
delivered = session.write(inputStr);
|
||||
if (!delivered && tagged) {
|
||||
session.forgetInputSeq(clientId as string, seq as number);
|
||||
}
|
||||
}
|
||||
|
||||
if (!waitPromise) return {};
|
||||
|
||||
try {
|
||||
// `send-keys` SUCCEEDS against a dead pane — tmux is happy to write into a corpse
|
||||
// — so a truthful `delivered` cannot come from the write's return value alone.
|
||||
// This is the case the field exists for: "delivered, but it timed out" tells an
|
||||
// agent to wait longer when the truth is "restart the worker".
|
||||
if (delivered && workerDead) {
|
||||
delivered = false;
|
||||
// The bytes went nowhere, so the seq must not be recorded as applied or the
|
||||
// caller's retry against a restarted worker is refused as a duplicate.
|
||||
if (!duplicate) undoOnFailure();
|
||||
}
|
||||
|
||||
// Nothing was written and nothing will be: no turn is coming, so blocking for the
|
||||
// full timeout would only delay the caller's real recovery by up to ten minutes.
|
||||
// Releasing the waiter also hands its slot back immediately.
|
||||
const selfReleased = !delivered && !duplicate;
|
||||
if (selfReleased) abort.abort();
|
||||
|
||||
const result = await waitPromise;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
delivered,
|
||||
duplicate,
|
||||
status: session.status,
|
||||
limitPaused: session.isLimitPaused,
|
||||
// Identical `wait` object to the two GET endpoints, so one client helper
|
||||
// reads all three, `timeoutMs` (post-clamp) included.
|
||||
//
|
||||
// `aborted` is the CLIENT-facing "you hung up, nobody is reading this", and
|
||||
// by that definition it is unobservable — which is exactly what the API
|
||||
// reference promises. The abort above is the server releasing its own waiter
|
||||
// on a delivery that failed, and the client IS reading this response, so
|
||||
// reporting `aborted: true` there would break that promise and hand an agent
|
||||
// a second, contradictory reason for the same outcome. `delivered: false`
|
||||
// already says what happened; `ended` says the wait was released early.
|
||||
wait: { ...result, aborted: selfReleased ? false : result.aborted, until: [...until] },
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
stopDeathWatch();
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Wait For A Signal (agent orchestration) ==========
|
||||
//
|
||||
// A bounded long-poll: block until the session hits one of `until`, then answer.
|
||||
// This exists because SSE is the only "tell me when" channel Codeman has, and an
|
||||
// agent driving the API from a shell tool cannot hold a stream and parse events
|
||||
// inline. See docs/agent-control-plan.md.
|
||||
//
|
||||
// A TIMEOUT IS A 200, not an error: callers are expected to loop over short waits
|
||||
// (proxies such as `tailscale serve` cut idle connections), and turning every poll
|
||||
// boundary into a 4xx would make that loop indistinguishable from a real failure.
|
||||
|
||||
app.get('/api/sessions/:id/wait', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = parseWaitQuery(SessionWaitQuerySchema, req.query, 'wait');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// An agent polls this URL in a loop with identical parameters. Any intermediary
|
||||
// applying heuristic freshness to the 200 would serve the stored `timedOut:true`
|
||||
// body to the next iteration instantly, turning the loop into a busy spin that
|
||||
// never observes the signal.
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
// Shared with the `wait` field on POST .../input: unknown token is a 400,
|
||||
// hook-only signals are rejected explicitly but dropped from the default.
|
||||
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
|
||||
if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
|
||||
|
||||
// The value actually applied after clamping, echoed below: a caller that asked
|
||||
// for 30 minutes and silently got 10 could not otherwise tell a poll boundary
|
||||
// from a wedged worker, and would kill a session that was working fine.
|
||||
const timeoutMs = clampWaitMs(query.timeout);
|
||||
|
||||
// Free the waiter when the caller hangs up; the response can no longer be sent by
|
||||
// then, so freeing the slot is the entire purpose.
|
||||
const abort = abortOnClientHangUp(reply);
|
||||
// A worker that dies while this request is parked emits nothing at all (the tmux
|
||||
// attach client survives it), so a wait would otherwise run to its full timeout.
|
||||
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
|
||||
|
||||
try {
|
||||
const result = await sessionWaits.waitForSignal(id, {
|
||||
until,
|
||||
timeoutMs,
|
||||
owner: ownerFor(req),
|
||||
abortSignal: abort.signal,
|
||||
requireTransition: query.fresh === '1' || query.fresh === 'true',
|
||||
// Read BEFORE awaiting: this is the state the caller is asking about.
|
||||
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: id,
|
||||
// Post-wait status, so a caller that timed out still learns where things stand.
|
||||
status: session.status,
|
||||
// A session paused on a usage limit emits nothing until its reset, so a
|
||||
// timeout here is expected rather than a stall worth retrying hard.
|
||||
limitPaused: session.isLimitPaused,
|
||||
// One shape across all three endpoints, so a single `is_done(resp)` helper
|
||||
// works against any of them. `result.timeoutMs` is the value actually
|
||||
// applied after clamping, which is what makes the clamp observable.
|
||||
wait: { ...result, until: [...until] },
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
|
||||
throw err;
|
||||
} finally {
|
||||
stopDeathWatch();
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Wait For Output (agent orchestration) ==========
|
||||
//
|
||||
// The companion to /wait: block until a literal string appears in this session's
|
||||
// output. Same 200-on-timeout contract. Fed by the `terminal` listener in
|
||||
// session-listener-wiring.ts, so what this scans is byte-for-byte what the pane
|
||||
// printed, ANSI stripped.
|
||||
//
|
||||
// ⚠️ A tmux repaint replays text already on screen, so `from=now` can match
|
||||
// something printed before the request. Callers need a marker unique per call.
|
||||
|
||||
app.get('/api/sessions/:id/wait-output', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
// Reject `regex` loudly instead of ignoring it. Matching is deliberately literal
|
||||
// (no ReDoS surface on a caller-supplied pattern over a live stream), and an agent
|
||||
// that assumed otherwise would silently wait on the wrong thing.
|
||||
if (req.query && typeof req.query === 'object' && 'regex' in req.query) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'regex is not supported; use match=<literal substring> (optionally with nocase=1)'
|
||||
);
|
||||
}
|
||||
|
||||
const query = parseWaitQuery(SessionWaitOutputQuerySchema, req.query, 'wait-output');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Same reason as /wait: this URL is polled in a loop with identical parameters.
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
const timeoutMs = clampWaitMs(query.timeout);
|
||||
const abort = abortOnClientHangUp(reply);
|
||||
const owner = ownerFor(req);
|
||||
// Output waiters are the ones a dead worker strands hardest: the feed simply stops.
|
||||
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
|
||||
|
||||
try {
|
||||
// Check the cap BEFORE touching the buffer. `session.terminalBuffer` is
|
||||
// `BufferAccumulator.value`, which joins the WHOLE accumulator (up to 32MB)
|
||||
// before the slice below takes its tail — so a request that is going to be
|
||||
// rejected anyway must not pay for a full materialization first, or the cap
|
||||
// provides no backpressure at all against a `from=buffer` loop.
|
||||
sessionWaits.assertCapacity(id, owner);
|
||||
|
||||
// `from=buffer` scans what already scrolled past before blocking. Bounded to a
|
||||
// tail: the buffer runs to 32MB and this is a per-request ANSI strip.
|
||||
let initialText: string | undefined;
|
||||
if (query.from === 'buffer') {
|
||||
const buffer = session.terminalBuffer;
|
||||
initialText =
|
||||
buffer.length > MAX_BUFFER_SCAN_BYTES ? buffer.slice(buffer.length - MAX_BUFFER_SCAN_BYTES) : buffer;
|
||||
}
|
||||
|
||||
const result = await sessionWaits.waitForOutput(id, {
|
||||
match: query.match,
|
||||
nocase: query.nocase === '1' || query.nocase === 'true',
|
||||
timeoutMs,
|
||||
owner,
|
||||
abortSignal: abort.signal,
|
||||
initialText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: id,
|
||||
status: session.status,
|
||||
limitPaused: session.isLimitPaused,
|
||||
// Same envelope as /wait; this one carries `matched`/`snippet`/`match`
|
||||
// where the signal wait carries `signal`/`until`.
|
||||
wait: { ...result, match: query.match },
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
|
||||
throw err;
|
||||
} finally {
|
||||
stopDeathWatch();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
@@ -2239,6 +2805,15 @@ export function registerSessionRoutes(
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||
// marker-guarded (a user's own skills/codeman is never touched). Claude mode only
|
||||
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
|
||||
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
|
||||
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
|
||||
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(resolvedCasePath);
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
WEBVIEW_PROBE_TIMEOUT_MS,
|
||||
WEBVIEW_PROXY_PREFIX,
|
||||
WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
@@ -420,6 +421,34 @@ async function proxyRequest(
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
});
|
||||
|
||||
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
|
||||
// the fetch bounded the entire exchange, so a legitimately slow endpoint (AI
|
||||
// inference behind the dashboard) and an actively streaming response both died at
|
||||
// 30s as an unlogged generic 502. The timer is cleared the moment headers arrive;
|
||||
// what reclaims an abandoned upstream afterwards is the client hangup below.
|
||||
const startedAt = Date.now();
|
||||
const abort = new AbortController();
|
||||
let headerTimedOut = false;
|
||||
let clientGone = false;
|
||||
const headerTimer = setTimeout(() => {
|
||||
headerTimedOut = true;
|
||||
abort.abort();
|
||||
}, WEBVIEW_UPSTREAM_TIMEOUT_MS);
|
||||
// A browser that navigates away mid-request (or mid-stream) must abort the
|
||||
// upstream fetch, or slow endpoints accumulate as orphaned upstream sockets.
|
||||
// Guarded by writableFinished, same as abortOnClientHangUp in session-routes:
|
||||
// `close` also fires after a completed response, which must not abort anything.
|
||||
reply.raw.on('close', () => {
|
||||
if (!reply.raw.writableFinished) {
|
||||
clientGone = true;
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitized request identity for logs: method + origin + path, never the query
|
||||
// string (it can carry the dashboard's tokens).
|
||||
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
@@ -431,11 +460,37 @@ async function proxyRequest(
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
|
||||
signal: abort.signal,
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (clientGone) {
|
||||
// Nobody is listening; the abort was ours and intentional. Not an upstream
|
||||
// failure, so no warn (it would read as the dashboard being broken).
|
||||
return reply;
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
console.warn(
|
||||
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
||||
`${logTarget} (webview "${webview.name}")`
|
||||
);
|
||||
return reply
|
||||
.code(502)
|
||||
.type('text/plain')
|
||||
.send(
|
||||
`Dashboard unreachable: upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms ` +
|
||||
`(CODEMAN_WEBVIEW_TIMEOUT_MS raises this limit)`
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[Webview] upstream fetch failed after ${elapsed}ms: ${logTarget} (webview "${webview.name}"): ${message}`
|
||||
);
|
||||
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
|
||||
} finally {
|
||||
// Headers arrived (or the fetch failed): from here on the timeout must never
|
||||
// fire, a streaming body is allowed to take as long as it takes.
|
||||
clearTimeout(headerTimer);
|
||||
}
|
||||
|
||||
const secureContext = req.protocol === 'https';
|
||||
@@ -581,7 +636,7 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -180,13 +180,19 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const cid = typeof msg.cid === 'string' ? msg.cid : null;
|
||||
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
|
||||
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
|
||||
let delivered = true;
|
||||
if (apply) {
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
delivered = session.write(msg.d);
|
||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||
// the client to drop the frame from its durable queue and left the seq
|
||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||
// a duplicate: the input was lost for good.
|
||||
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
|
||||
}
|
||||
if (seq !== null && socket.readyState === 1) {
|
||||
if (delivered && seq !== null && socket.readyState === 1) {
|
||||
socket.send(`{"t":"ia","seq":${seq}}`);
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -759,6 +760,14 @@ export const SettingsUpdateSchema = z
|
||||
/** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Inject the Codeman agent skill (`skills/codeman`) into `<case>/.claude/skills/`
|
||||
* on Claude session create, so an agent inside the session can drive the API
|
||||
* (see docs/agent-control-plan.md §2). SYNCED, default OFF: every skill's
|
||||
* name+description costs context on every turn, so it is opt-in. Injection is
|
||||
* add-only at create; a marker keeps user-authored copies untouched.
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
@@ -911,6 +920,66 @@ export const SessionInputWithLimitSchema = z.object({
|
||||
// unset rather than sending null. See docs/reliable-input-delivery.md.
|
||||
seq: z.number().int().nonnegative().optional(),
|
||||
clientId: z.string().max(128).optional(),
|
||||
// Send-and-wait (agent orchestration): `true` for the default signal set, or the
|
||||
// same grammar as `GET .../wait` — a comma string or an array of signals. Absent
|
||||
// means the historical fire-and-forget behavior, byte for byte.
|
||||
//
|
||||
// `.nullish()`, not `.optional()`: a third-party caller building the body with
|
||||
// JSON.stringify keeps an explicit null on the wire, and `.optional()` rejects it
|
||||
// with INVALID_INPUT. That gotcha has shipped as a real bug twice.
|
||||
wait: z.union([z.boolean(), z.string().max(120), z.array(z.string().max(120)).max(8)]).nullish(),
|
||||
// Unbounded above: the effective value is clamped to MAX_WAIT_MS server-side and
|
||||
// returned as `data.wait.timeoutMs`, so a caller that asks for 24h sees what it
|
||||
// actually got. A `.max()` here would turn the same documented clamp into a 400 for
|
||||
// large-enough guesses, which is the one behaviour an agent cannot predict.
|
||||
waitTimeout: z.number().int().positive().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Query validation for `GET /api/sessions/:id/wait` (agent wait primitives).
|
||||
*
|
||||
* Everything arrives as a string. `timeout` is coerced and bounded here, then
|
||||
* clamped again to the operator's ceiling by `clampWaitMs()` — the schema bound
|
||||
* only keeps an absurd number out of the arithmetic. A non-numeric `timeout` is a
|
||||
* 400 rather than a silent fallback, so an agent never believes it asked for a
|
||||
* longer wait than it got; the value actually applied comes back as
|
||||
* `data.wait.timeoutMs`, which is what makes the clamp observable. `until` is
|
||||
* parsed by `parseWaitSignals()`, which reports unknown tokens instead of
|
||||
* dropping them.
|
||||
*
|
||||
* `until` accepts an ARRAY as well as the comma string: `?until=stop&until=exit`
|
||||
* is how most HTTP clients express a list, Fastify's query parser delivers a
|
||||
* repeated parameter as an array, and `parseWaitSignals()` has always handled
|
||||
* both. Rejecting the repeated form left that branch unreachable and 400'd the
|
||||
* more natural spelling.
|
||||
*/
|
||||
export const SessionWaitQuerySchema = z.object({
|
||||
until: z.union([z.string().max(120), z.array(z.string().max(120)).max(8)]).optional(),
|
||||
// No upper bound on purpose. The contract is "clamped to [MIN_WAIT_MS, MAX_WAIT_MS]",
|
||||
// and a `.max()` here contradicted it: `timeout=99999999` was a 400 mid-fan-out while
|
||||
// `timeout=600001` was silently clamped, so the same documented rule produced two
|
||||
// different outcomes depending on how big the caller's guess was. `clampWaitMs()`
|
||||
// bounds every finite value, and `.int()` still rejects `Infinity`/`1e999` and junk.
|
||||
timeout: z.coerce.number().int().positive().optional(),
|
||||
fresh: z.enum(['0', '1', 'true', 'false']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Query validation for `GET /api/sessions/:id/wait-output`.
|
||||
*
|
||||
* `match` is a LITERAL substring, never a pattern: `search-service.ts` avoids regex
|
||||
* so there is no ReDoS surface, and this endpoint is more exposed still (the pattern
|
||||
* would be caller-supplied and the input is a live stream). The length bound is a
|
||||
* second reason the carry buffer stays small. The route separately rejects a `regex`
|
||||
* parameter outright rather than ignoring it.
|
||||
*/
|
||||
export const SessionWaitOutputQuerySchema = z.object({
|
||||
match: z.string().min(MIN_MATCH_LENGTH).max(MAX_MATCH_LENGTH),
|
||||
nocase: z.enum(['0', '1', 'true', 'false']).optional(),
|
||||
from: z.enum(['now', 'buffer']).optional(),
|
||||
// Unbounded above for the same reason as SessionWaitQuerySchema.timeout: clamping is
|
||||
// the documented contract, so a large value must clamp rather than 400.
|
||||
timeout: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// ========== Session Mutation Routes ==========
|
||||
|
||||
@@ -30,6 +30,7 @@ import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
InstallInfo,
|
||||
@@ -43,10 +44,9 @@ import type {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json') as { version: string };
|
||||
|
||||
/** systemd unit name (matches install.sh + scripts/codeman-web.service). */
|
||||
const SYSTEMD_UNIT = 'codeman-web.service';
|
||||
/** launchd agent label (matches install.sh setup_launchd_service). */
|
||||
const LAUNCHD_LABEL = 'com.codeman.web';
|
||||
// Unit name / job label live in config/service-names.ts so install.sh, this
|
||||
// detector and `codeman service install` cannot drift apart. Unchanged for the
|
||||
// default instance.
|
||||
/** Path to the persisted update status file. */
|
||||
const STATUS_FILE = dataPath('update-status.json');
|
||||
/** Network/git timeout for the "check" path (longer than EXEC_TIMEOUT_MS — ls-remote hits the network). */
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
attachSessionListeners,
|
||||
detachSessionListeners,
|
||||
} from './session-listener-wiring.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
setupTimedRespawn,
|
||||
@@ -621,6 +622,7 @@ export class WebServer extends EventEmitter {
|
||||
getModelConfig: this.getModelConfig.bind(this),
|
||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
|
||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||
getLightState: this.getLightState.bind(this),
|
||||
getLightSessionsState: this.getLightSessionsState.bind(this),
|
||||
@@ -805,7 +807,24 @@ export class WebServer extends EventEmitter {
|
||||
const clientId =
|
||||
typeof query.clientId === 'string' && SSE_CLIENT_ID_RE.test(query.clientId) ? query.clientId : undefined;
|
||||
|
||||
// Carry over the headers the security hook already set on this reply.
|
||||
//
|
||||
// writeHead goes straight to the Node response and bypasses Fastify's header
|
||||
// store, so everything the onRequest hook granted is silently dropped —
|
||||
// including the Access-Control-Allow-Origin it emits for localhost origins.
|
||||
// The result is an internal contradiction: a localhost page may call every
|
||||
// /api endpoint cross-origin, but its EventSource fails CORS. The security
|
||||
// headers (nosniff, frame-options, CSP) were lost the same way.
|
||||
//
|
||||
// The other raw-writeHead routes live in file-routes.ts and share a helper;
|
||||
// this one keeps its own copy so the server does not import from a route
|
||||
// module it registers.
|
||||
const inherited: Record<string, number | string | string[]> = {};
|
||||
for (const [name, value] of Object.entries(reply.getHeaders())) {
|
||||
if (value !== undefined) inherited[name] = value;
|
||||
}
|
||||
reply.raw.writeHead(200, {
|
||||
...inherited,
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
@@ -1230,6 +1249,16 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Release anything blocked on this session, in the documented order: 'exit'
|
||||
// first so an until=exit caller gets its signal, then cancelAll so everyone
|
||||
// else resolves with ended:true instead of timing out.
|
||||
//
|
||||
// The 'exit' here is NOT redundant with the PTY-exit listener: listeners are
|
||||
// detached a few lines above, before `session.stop()`, so on a delete the
|
||||
// session's own exit event never reaches the registry.
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
@@ -1624,6 +1653,13 @@ export class WebServer extends EventEmitter {
|
||||
return resolveTerminalHistoryConfig(settings);
|
||||
}
|
||||
|
||||
// Whether the Codeman agent skill is injected into cases on Claude session create
|
||||
// (synced `agentSkillEnabled` setting, default OFF; docs/agent-control-plan.md §2).
|
||||
private async getAgentSkillEnabled(): Promise<boolean> {
|
||||
const settings = await this.readSettings();
|
||||
return settings.agentSkillEnabled === true;
|
||||
}
|
||||
|
||||
// Helper to get model configuration from settings
|
||||
private async getModelConfig(): Promise<{
|
||||
defaultModel?: string;
|
||||
@@ -2828,6 +2864,11 @@ export class WebServer extends EventEmitter {
|
||||
// Gracefully close all SSE connections and clear batching state
|
||||
this.sse.stop();
|
||||
|
||||
// Release every pending long-poll waiter. Their timers are deliberately not
|
||||
// unref'd (an unref'd timer can let the process exit mid-wait and strand the
|
||||
// response), so without this a 10-minute wait holds shutdown open.
|
||||
sessionWaits.cancelEverything();
|
||||
|
||||
this.lastRecordedTokens.clear();
|
||||
|
||||
// Stop multiplexer and flush pending saves
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
|
||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||
export interface SessionListenerRefs {
|
||||
@@ -92,6 +93,9 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
|
||||
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
|
||||
terminal: (data) => {
|
||||
// Feeds `GET /api/sessions/:id/wait-output`. No-ops with a single Map lookup
|
||||
// when nothing is waiting, which is the case on virtually every chunk.
|
||||
sessionWaits.notifyOutput(session.id, data);
|
||||
deps.batchTerminalData(session.id, data);
|
||||
},
|
||||
|
||||
@@ -137,6 +141,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
|
||||
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
||||
exit: (code) => {
|
||||
// Before anything that can throw: a caller blocked on this session must learn
|
||||
// the process died rather than sit until its timeout.
|
||||
//
|
||||
// Both halves are required, in this order — the same pair `_doCleanupSession`
|
||||
// uses on the delete path, for the same reason. `notifySignal` resolves ONLY
|
||||
// waiters that asked for `exit`; everyone else (`until=working`, `until=stop`,
|
||||
// every wait-output) would keep a slot in the process-wide pool until their
|
||||
// timeout, on a session whose feeds this very handler is about to tear down:
|
||||
// `removeSessionListenerRefs` below detaches the `terminal` listener that is
|
||||
// the only input to `notifyOutput`, and the `idle`/`working` listeners with it.
|
||||
// Nothing can reach those waiters afterwards, so holding them is a guaranteed
|
||||
// ten-minute lie. `cancelAll` answers them `ended: true`, which the plan's §3.6
|
||||
// specifies for exactly this case ("Never hang").
|
||||
//
|
||||
// Safe against the respawn cycle: a respawn writes `/clear` + a kickstart
|
||||
// prompt through the mux and never restarts the PTY, so it emits no `exit` and
|
||||
// cannot cancel an orchestrating agent's wait. And for an agent driving a
|
||||
// worker this is the right trade even when the PTY exit was only a tmux
|
||||
// DETACH: `ended` means "re-check and re-issue", one extra round trip, versus
|
||||
// burning the caller's entire timeout learning nothing.
|
||||
sessionWaits.notifySignal(session.id, 'exit');
|
||||
sessionWaits.cancelAll(session.id);
|
||||
getLifecycleLog().log({
|
||||
event: 'exit',
|
||||
sessionId: session.id,
|
||||
@@ -187,6 +213,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
|
||||
/** Broadcasts `session:working` — Claude started processing */
|
||||
working: () => {
|
||||
sessionWaits.notifySignal(session.id, 'working');
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
@@ -197,6 +224,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
sessionWaits.notifySignal(session.id, 'idle');
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview Static guard: every endpoint the packaged agent skill documents
|
||||
* still exists in the routes it is documenting.
|
||||
*
|
||||
* `skills/codeman/reference/endpoints.md` is injected into cases and read by agents
|
||||
* driving Codeman over HTTP. Nothing tied it to the server, so renaming or dropping a
|
||||
* route left the skill confidently telling agents to call a 404. This parses the
|
||||
* `METHOD /api/...` pairs out of the doc and matches them against the `app.<method>()`
|
||||
* registrations in src/web/routes/*.ts.
|
||||
*
|
||||
* Precision over recall on purpose: only a bare uppercase verb followed by an
|
||||
* `/api/...` path counts, so prose that merely mentions a path (the `.../sessions/null`
|
||||
* jq-pitfall example) is ignored, and a spuriously failing guard does not get deleted
|
||||
* by the next person. `/api/v1` is a URL-rewrite alias (server.ts), so the version
|
||||
* segment is dropped before matching, and param NAMES are normalized away since the
|
||||
* doc's `:id` need not match a route's `:sessionId`.
|
||||
*
|
||||
* Port: N/A (pure static analysis).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const DOC_PATH = join(HERE, '../skills/codeman/reference/endpoints.md');
|
||||
const ROUTES_DIR = join(HERE, '../src/web/routes');
|
||||
|
||||
/** `METHOD /api/<path>`, stopping before a query string, backtick or prose. */
|
||||
const DOC_ENDPOINT = /\b(GET|POST|PUT|PATCH|DELETE)\s+\/(api\/[A-Za-z0-9_:/-]+)/g;
|
||||
/** `app.get('/api/…'`, where the path may sit on its own line (case-routes.ts, file-routes.ts). */
|
||||
const ROUTE_REGISTRATION = /app\.(get|post|put|patch|delete)\(\s*'([^']+)'/g;
|
||||
|
||||
/**
|
||||
* Strip the `/api/v1` alias and replace param names with a placeholder, so
|
||||
* `GET /api/v1/sessions/:id` and `app.get('/api/sessions/:sessionId')` compare equal.
|
||||
*/
|
||||
function normalize(method: string, path: string): string {
|
||||
const withoutVersion = path.replace(/^\/api\/v1\//, '/api/');
|
||||
const params = withoutVersion.replace(/\/:[^/]+/g, '/:p').replace(/\/$/, '');
|
||||
return `${method.toUpperCase()} ${params}`;
|
||||
}
|
||||
|
||||
function documentedEndpoints(): string[] {
|
||||
const markdown = readFileSync(DOC_PATH, 'utf-8');
|
||||
const found = new Set<string>();
|
||||
for (const match of markdown.matchAll(DOC_ENDPOINT)) {
|
||||
found.add(normalize(match[1], `/${match[2]}`));
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
function registeredRoutes(): Set<string> {
|
||||
const registered = new Set<string>();
|
||||
for (const file of readdirSync(ROUTES_DIR)) {
|
||||
if (!file.endsWith('.ts')) continue;
|
||||
const source = readFileSync(join(ROUTES_DIR, file), 'utf-8');
|
||||
for (const match of source.matchAll(ROUTE_REGISTRATION)) {
|
||||
if (!match[2].startsWith('/api/')) continue;
|
||||
registered.add(normalize(match[1], match[2]));
|
||||
}
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
describe('skills/codeman/reference/endpoints.md', () => {
|
||||
it('parses a plausible number of endpoints out of the doc', () => {
|
||||
// A parser that silently matches nothing would make the real assertion below
|
||||
// pass vacuously forever.
|
||||
const documented = documentedEndpoints();
|
||||
expect(documented.length).toBeGreaterThanOrEqual(10);
|
||||
expect(documented).toContain('POST /api/quick-start');
|
||||
expect(documented).toContain('GET /api/sessions/:p/wait');
|
||||
});
|
||||
|
||||
it('finds the route registrations it matches against', () => {
|
||||
const registered = registeredRoutes();
|
||||
expect(registered.size).toBeGreaterThan(100);
|
||||
expect(registered.has('GET /api/status')).toBe(true);
|
||||
});
|
||||
|
||||
it('documents only endpoints that are actually registered', () => {
|
||||
const registered = registeredRoutes();
|
||||
const missing = documentedEndpoints().filter((endpoint) => !registered.has(endpoint));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the agent-skill injection helpers in hooks-config.ts
|
||||
* (`applyAgentSkill`, `installAgentSkillInto`, `removeAgentSkillFrom`).
|
||||
*
|
||||
* These run against the REAL packaged source (`skills/codeman/` at the repo root),
|
||||
* so they double as a guard that the skill files exist and are readable: an npm
|
||||
* publish without them would be caught here before the `files` entry silently
|
||||
* ignores the missing directory.
|
||||
*
|
||||
* Pure filesystem tests in a per-test temp dir. Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { applyAgentSkill, installAgentSkillInto, removeAgentSkillFrom } from '../src/hooks-config.js';
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
let casePath: string;
|
||||
const skillDir = () => join(casePath, '.claude', 'skills', 'codeman');
|
||||
|
||||
beforeEach(async () => {
|
||||
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
it('installs SKILL.md (marker appended) and the reference files from the packaged source', async () => {
|
||||
const result = await applyAgentSkill(casePath, true);
|
||||
expect(result).toBe('installed');
|
||||
|
||||
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
|
||||
// Reference files ride along byte-for-byte (no marker there).
|
||||
const sourceEndpoints = await readFile(
|
||||
join(process.cwd(), 'skills', 'codeman', 'reference', 'endpoints.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const injectedEndpoints = await readFile(join(skillDir(), 'reference', 'endpoints.md'), 'utf-8');
|
||||
expect(injectedEndpoints).toBe(sourceEndpoints);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'recipes.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent: a second run reports unchanged', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('refreshes a stale Codeman-managed copy back to the packaged content', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const original = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
// Simulate an older injected version: content differs but the marker is intact.
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale content\n${MARKER_PREFIX}: old -->\n`);
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toBe(original);
|
||||
});
|
||||
|
||||
it('never clobbers a user-authored skills/codeman (no marker)', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), '---\nname: codeman\n---\nmy own skill\n');
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
|
||||
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
|
||||
await mkdir(join(casePath, '.claude', 'skills'), { recursive: true });
|
||||
await symlink(join(casePath, 'elsewhere'), skillDir());
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skills/ parent', async () => {
|
||||
await mkdir(join(casePath, 'real-skills'), { recursive: true });
|
||||
await mkdir(join(casePath, '.claude'), { recursive: true });
|
||||
await symlink(join(casePath, 'real-skills'), join(casePath, '.claude', 'skills'));
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
it('removes our copy and prunes the emptied directories', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
|
||||
// `.claude` itself is not ours to prune.
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports absent when there is nothing to remove', async () => {
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('absent');
|
||||
});
|
||||
|
||||
it('leaves a user-authored copy untouched', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), 'my own skill\n');
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('foreign');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a user's extra files in the directory (no rm -rf)", async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), 'reference', 'my-notes.md'), 'mine\n');
|
||||
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(false);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
|
||||
// The user's file and the directories holding it survive.
|
||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,8 @@ describe('AiIdleChecker', () => {
|
||||
describe('Output Parsing', () => {
|
||||
it('should parse IDLE verdict', async () => {
|
||||
// Set up mock to return IDLE result after polling
|
||||
mockedReadFileSync.mockReturnValueOnce('') // writeFileSync creates empty file
|
||||
mockedReadFileSync
|
||||
.mockReturnValueOnce('') // writeFileSync creates empty file
|
||||
.mockReturnValueOnce('IDLE\nSession shows completion message and prompt.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
@@ -87,7 +88,8 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should parse WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
mockedReadFileSync
|
||||
.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nSpinner characters detected, still processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
@@ -100,8 +102,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should handle lowercase verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -112,7 +113,8 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should return ERROR for unparseable output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
mockedReadFileSync
|
||||
.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('Something unexpected happened.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
@@ -125,8 +127,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should return ERROR for empty output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -175,10 +176,34 @@ describe('AiIdleChecker', () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('codeman-aicheck-'),
|
||||
''
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith(expect.stringContaining('codeman-aicheck-'), '');
|
||||
});
|
||||
|
||||
it('should keep Claude stderr separate from verdict output', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
const spawnArgs = mockedSpawn.mock.calls[0]?.[1];
|
||||
const command = spawnArgs?.[spawnArgs.length - 1];
|
||||
expect(command).toEqual(expect.any(String));
|
||||
expect(command).toContain(' 2> "');
|
||||
expect(command).not.toContain('2>&1');
|
||||
});
|
||||
|
||||
it('should include Claude stderr when no verdict is produced', async () => {
|
||||
mockedReadFileSync.mockImplementation((path) =>
|
||||
String(path).includes('-stderr-') ? 'Claude CLI failed to load settings' : '__AICHECK_DONE__'
|
||||
);
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Claude CLI failed to load settings');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +248,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
// Should have tried to kill the tmux session (initial kill + cleanup kill)
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
call => typeof call[0] === 'string' && call[0].includes('kill-session')
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('kill-session')
|
||||
);
|
||||
expect(killCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -236,8 +261,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
describe('Cooldown', () => {
|
||||
it('should start cooldown after WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -250,8 +274,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should return to ready after cooldown expires', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -267,8 +290,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should not start new check during cooldown', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -283,8 +305,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should start error cooldown after parse error', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage output\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage output\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -302,8 +323,7 @@ describe('AiIdleChecker', () => {
|
||||
const cooldowns = [1100, 2100]; // Wait slightly longer than each cooldown
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -321,8 +341,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
it('should reset error counter on successful check', async () => {
|
||||
// First check: error
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await firstCheck;
|
||||
@@ -332,8 +351,7 @@ describe('AiIdleChecker', () => {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
|
||||
// Second check: success
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__');
|
||||
const secondCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await secondCheck;
|
||||
@@ -352,8 +370,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
describe('Buffer Handling', () => {
|
||||
it('should strip ANSI codes from terminal buffer', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const ansiBuffer = '\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m text';
|
||||
const checkPromise = checker.check(ansiBuffer);
|
||||
@@ -365,8 +382,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should trim buffer to maxContextChars', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
// Create buffer longer than maxContextChars (1000)
|
||||
const longBuffer = 'x'.repeat(2000);
|
||||
@@ -402,8 +418,7 @@ describe('AiIdleChecker', () => {
|
||||
describe('Reset', () => {
|
||||
it('should clear all state on reset', async () => {
|
||||
// Trigger a WORKING verdict to set state
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -440,24 +455,24 @@ describe('AiIdleChecker', () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('checkCompleted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
verdict: 'IDLE',
|
||||
}));
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
verdict: 'IDLE',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit cooldownStarted event after WORKING', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('cooldownStarted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -477,8 +492,7 @@ describe('AiIdleChecker', () => {
|
||||
const cooldowns = [1100, 2100]; // Wait longer than exponential backoff
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Issue #205, round 2: `getClaudeCliVersion()` used to cache FAILURE forever.
|
||||
*
|
||||
* It stored `null` on any exception and guarded on `!== undefined`, so a single
|
||||
* failed probe — the 5s exec timeout, a PATH-starved systemd/launchd
|
||||
* environment, a transient fs hiccup — at the first Claude session start left
|
||||
* `cliVersion` undefined for every Claude session until the server restarted.
|
||||
* An undefined `cliVersion` silently disables wheel-forwarding to Claude's own
|
||||
* transcript (`_shouldForwardWheelToApp`), which is the only route to history
|
||||
* for a repaint-mode pane: a dead wheel on every device at once, which is what
|
||||
* the reporter described (phone + iPad + laptop all broken together points at a
|
||||
* SERVER-side cause, not a browser one).
|
||||
*
|
||||
* The probe itself can't run under vitest (it would spawn a real `claude`), so
|
||||
* these drive the cache policy directly with an injected probe and clock.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
claudeVersionRetryDelayMs,
|
||||
getClaudeCliVersion,
|
||||
resolveClaudeCliVersion,
|
||||
type ClaudeVersionProbeState,
|
||||
} from '../src/utils/claude-cli-resolver.js';
|
||||
|
||||
const freshState = (): ClaudeVersionProbeState => ({ failures: 0, lastFailureAt: 0 });
|
||||
|
||||
describe('claude --version probe caching', () => {
|
||||
it('probes once on success and never spawns again', () => {
|
||||
const state = freshState();
|
||||
const probe = vi.fn(() => '2.1.223');
|
||||
|
||||
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBe('2.1.223');
|
||||
expect(resolveClaudeCliVersion(state, 2_000, probe)).toBe('2.1.223');
|
||||
expect(resolveClaudeCliVersion(state, 9_999_999, probe)).toBe('2.1.223');
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('RETRIES after a failed probe instead of poisoning the process', () => {
|
||||
const state = freshState();
|
||||
const probe = vi
|
||||
.fn<() => string | null>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('spawn claude ETIMEDOUT'); // the shipped failure mode
|
||||
})
|
||||
.mockImplementationOnce(() => '2.1.223');
|
||||
|
||||
// First session start: probe blows up, no version.
|
||||
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull();
|
||||
// Immediately after, the negative cache holds — no probe storm.
|
||||
expect(resolveClaudeCliVersion(state, 30_000, probe)).toBeNull();
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the retry window elapses, the next session start probes again and
|
||||
// wheel-forwarding comes back without a server restart.
|
||||
expect(resolveClaudeCliVersion(state, 61_000, probe)).toBe('2.1.223');
|
||||
expect(probe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('treats an unparseable version like a failure (retryable, not cached)', () => {
|
||||
const state = freshState();
|
||||
const probe = vi.fn<() => string | null>(() => null); // e.g. output without a x.y.z
|
||||
|
||||
expect(resolveClaudeCliVersion(state, 1_000, probe)).toBeNull();
|
||||
expect(resolveClaudeCliVersion(state, 61_000, probe)).toBeNull();
|
||||
expect(probe).toHaveBeenCalledTimes(2);
|
||||
expect(state.version).toBeUndefined(); // nothing cached as "known bad"
|
||||
});
|
||||
|
||||
it('clears the failure streak once a probe succeeds', () => {
|
||||
const state = freshState();
|
||||
const probe = vi
|
||||
.fn<() => string | null>()
|
||||
.mockImplementationOnce(() => null)
|
||||
.mockImplementationOnce(() => '2.1.223');
|
||||
|
||||
resolveClaudeCliVersion(state, 1_000, probe);
|
||||
expect(state.failures).toBe(1);
|
||||
resolveClaudeCliVersion(state, 61_000, probe);
|
||||
expect(state.failures).toBe(0);
|
||||
expect(state.lastFailureAt).toBe(0);
|
||||
});
|
||||
|
||||
it('backs off so a genuinely missing binary cannot probe on every session start', () => {
|
||||
expect(claudeVersionRetryDelayMs(0)).toBe(0);
|
||||
expect(claudeVersionRetryDelayMs(1)).toBe(60_000);
|
||||
expect(claudeVersionRetryDelayMs(2)).toBe(120_000);
|
||||
expect(claudeVersionRetryDelayMs(3)).toBe(240_000);
|
||||
// Capped, so it keeps retrying forever without ever spinning.
|
||||
expect(claudeVersionRetryDelayMs(50)).toBe(15 * 60_000);
|
||||
|
||||
const state = freshState();
|
||||
const probe = vi.fn<() => string | null>(() => null);
|
||||
resolveClaudeCliVersion(state, 0, probe); // failure 1 → retry at 60s
|
||||
resolveClaudeCliVersion(state, 30_000, probe); // still inside the window
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
resolveClaudeCliVersion(state, 60_000, probe); // failure 2 → retry at 120s
|
||||
resolveClaudeCliVersion(state, 119_000, probe);
|
||||
expect(probe).toHaveBeenCalledTimes(2);
|
||||
resolveClaudeCliVersion(state, 180_001, probe);
|
||||
expect(probe).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('stays hermetic under vitest without recording a phantom failure', () => {
|
||||
// The guard returns before the probe, and — unlike the old code, which wrote
|
||||
// null into the cache here — leaves the cache untouched.
|
||||
expect(getClaudeCliVersion()).toBeNull();
|
||||
expect(getClaudeCliVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview Tests for `codeman skill install|uninstall` target resolution
|
||||
* (`resolveCliCasePath` / `resolveSkillTargetPath` in src/cli.ts).
|
||||
*
|
||||
* The linked-cases lookup shipped in 1.14.2 with no guard: before it, `--case`
|
||||
* rejected every case linked in from outside `~/codeman-cases` with "Case not
|
||||
* found" even though the server resolved the same name fine. These tests pin both
|
||||
* halves of that resolution (registry first, cases dir as fallback) and the
|
||||
* tolerance rules around a missing or malformed registry.
|
||||
*
|
||||
* `test/setup.ts` gives this file its own temporary HOME, so `homedir()` and
|
||||
* `dataPath()` already point into a per-file fixture: no os mocking needed.
|
||||
* Port: N/A (pure path resolution, no server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
|
||||
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
const LINKED_ROOT = join(homedir(), 'elsewhere');
|
||||
|
||||
/** Where the packaged skill lands under a case. Mirrors `applyAgentSkill()`. */
|
||||
function skillDirIn(casePath: string): string {
|
||||
return join(casePath, '.claude', 'skills', 'codeman');
|
||||
}
|
||||
|
||||
function writeLinkedCases(content: string): void {
|
||||
mkdirSync(dataPath(), { recursive: true });
|
||||
writeFileSync(LINKED_CASES_FILE, content, 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rmSync(LINKED_CASES_FILE, { force: true });
|
||||
rmSync(CASES_DIR, { recursive: true, force: true });
|
||||
rmSync(LINKED_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(LINKED_CASES_FILE, { force: true });
|
||||
rmSync(CASES_DIR, { recursive: true, force: true });
|
||||
rmSync(LINKED_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveSkillTargetPath (global)', () => {
|
||||
it('targets the user-scope skill dir when no --case is given', () => {
|
||||
expect(resolveSkillTargetPath({})).toEqual({
|
||||
target: join(homedir(), '.claude', 'skills', 'codeman'),
|
||||
});
|
||||
});
|
||||
|
||||
it('never consults the linked-cases registry for the global target', () => {
|
||||
// A registry entry named after nothing in particular must not divert the
|
||||
// global install, which is not case-scoped at all.
|
||||
writeLinkedCases(JSON.stringify({ anything: join(LINKED_ROOT, 'anything') }));
|
||||
expect(resolveSkillTargetPath({}).target).toBe(join(homedir(), '.claude', 'skills', 'codeman'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCliCasePath / resolveSkillTargetPath (--case)', () => {
|
||||
it('resolves a LINKED case through linked-cases.json, not the cases dir', () => {
|
||||
// The 1.14.2 regression: a case linked in from outside ~/codeman-cases was
|
||||
// resolved to a cases-dir path that does not exist, so install refused it.
|
||||
const linkedPath = join(LINKED_ROOT, 'my-repo');
|
||||
mkdirSync(linkedPath, { recursive: true });
|
||||
writeLinkedCases(JSON.stringify({ 'my-repo': linkedPath }));
|
||||
|
||||
expect(resolveCliCasePath('my-repo')).toBe(linkedPath);
|
||||
expect(resolveSkillTargetPath({ case: 'my-repo' })).toEqual({ target: skillDirIn(linkedPath) });
|
||||
expect(existsSync(join(CASES_DIR, 'my-repo'))).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the cases dir for a name the registry does not list', () => {
|
||||
const casePath = join(CASES_DIR, 'plain-case');
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
writeLinkedCases(JSON.stringify({ 'other-case': join(LINKED_ROOT, 'other-case') }));
|
||||
|
||||
expect(resolveCliCasePath('plain-case')).toBe(casePath);
|
||||
expect(resolveSkillTargetPath({ case: 'plain-case' })).toEqual({ target: skillDirIn(casePath) });
|
||||
});
|
||||
|
||||
it('reports the resolved path instead of exiting when the case does not exist', () => {
|
||||
// process.exit(1) lives in the CLI wrapper on purpose: calling it here would
|
||||
// kill the test runner.
|
||||
expect(resolveSkillTargetPath({ case: 'nope' })).toEqual({ missingCase: join(CASES_DIR, 'nope') });
|
||||
});
|
||||
|
||||
it('reports the LINKED path when the registry points at a directory that is gone', () => {
|
||||
const linkedPath = join(LINKED_ROOT, 'moved-away');
|
||||
writeLinkedCases(JSON.stringify({ 'moved-away': linkedPath }));
|
||||
|
||||
expect(resolveSkillTargetPath({ case: 'moved-away' })).toEqual({ missingCase: linkedPath });
|
||||
});
|
||||
});
|
||||
|
||||
describe('linked-cases.json tolerance', () => {
|
||||
const casePath = () => join(CASES_DIR, 'tolerant');
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(casePath(), { recursive: true });
|
||||
});
|
||||
|
||||
it('degrades to the cases dir when the registry file is absent', () => {
|
||||
expect(existsSync(LINKED_CASES_FILE)).toBe(false);
|
||||
expect(resolveSkillTargetPath({ case: 'tolerant' })).toEqual({ target: skillDirIn(casePath()) });
|
||||
});
|
||||
|
||||
it('degrades to the cases dir on malformed JSON rather than throwing', () => {
|
||||
writeLinkedCases('{ not json at all');
|
||||
expect(() => resolveCliCasePath('tolerant')).not.toThrow();
|
||||
expect(resolveSkillTargetPath({ case: 'tolerant' })).toEqual({ target: skillDirIn(casePath()) });
|
||||
});
|
||||
|
||||
it('degrades to the cases dir when the registry is valid JSON of the wrong shape', () => {
|
||||
// A null / array / non-string-valued entry must read as "no linked case",
|
||||
// never as a target path.
|
||||
for (const body of ['null', '[]', JSON.stringify({ tolerant: 42 }), JSON.stringify({ tolerant: '' })]) {
|
||||
writeLinkedCases(body);
|
||||
expect(resolveCliCasePath('tolerant')).toBe(casePath());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill command wiring', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global', () => {
|
||||
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
|
||||
expect(skill).toBeDefined();
|
||||
|
||||
const subcommands = skill!.commands.map((cmd) => cmd.name());
|
||||
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
|
||||
|
||||
for (const name of ['install', 'uninstall']) {
|
||||
const flags = skill!.commands
|
||||
.find((cmd) => cmd.name() === name)!
|
||||
.options.map((opt) => opt.long)
|
||||
.sort();
|
||||
expect(flags).toEqual(['--case', '--global']);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* @fileoverview Predictive-echo E2E against a REAL codex 0.147 TUI (issues
|
||||
* #218/#219/#220/#222 retest scenarios + the byte-identity and simulated-RTT
|
||||
* pins). CI-EXCLUDED (needs chromium + the codex binary); a REQUIRED item of
|
||||
* the release checklist.
|
||||
*
|
||||
* Unlike the other Playwright tests this one cannot use the in-process
|
||||
* WebServer: under VITEST the mux layer is a pure in-memory mock and Session
|
||||
* spawns an echo PTY, so no real codex would ever run. The lab server is a
|
||||
* CHILD PROCESS with the VITEST markers stripped from its env, isolated via
|
||||
* CODEMAN_INSTANCE=codexlab (own data dir under the per-test fixture HOME +
|
||||
* own tmux socket) on port 3222 (3220/3221 are taken; see the port sweep in
|
||||
* the plan). Codex runs against a throwaway CODEX_HOME with a fake API key
|
||||
* (never leaves the box: the first request 401s, which is fine — every
|
||||
* scenario here is about the composer, not completions).
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.config.ts test/codex-predictive-echo.test.ts
|
||||
*/
|
||||
import { execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { userInfo } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
|
||||
const PORT = 3222;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const INSTANCE = 'codexlab';
|
||||
const TMUX = `tmux -L codeman-${INSTANCE}`;
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const CODEX_BIN_DIR = `${userInfo().homedir}/.local/bin`;
|
||||
|
||||
let server: ChildProcess | null = null;
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let codexHome: string;
|
||||
let trustedWorkdir: string;
|
||||
let untrustedWorkdir: string;
|
||||
let sessionId: string;
|
||||
const createdSessions: string[] = [];
|
||||
|
||||
function hasCodex(): boolean {
|
||||
try {
|
||||
execSync(`PATH="${CODEX_BIN_DIR}:$PATH" codex --version`, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[codex-e2e] codex unavailable:', (e as Error).message.slice(0, 300));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const paneByTail = new Map<string, string>();
|
||||
|
||||
/** Rendered pane text (tmux ground truth). Panes are found once by the
|
||||
* workdir basename codex prints in its banner, then cached by pane id —
|
||||
* the lab tmux socket is exclusive to this test. */
|
||||
function capturePaneFor(workdir: string): string {
|
||||
const tail = workdir.split('/').pop()!;
|
||||
const panes = execSync(`${TMUX} list-panes -a -F '#{pane_id}'`, { encoding: 'utf8' }).trim().split('\n');
|
||||
const capture = (p: string) => execSync(`${TMUX} capture-pane -p -t '${p}'`, { encoding: 'utf8' });
|
||||
const cached = paneByTail.get(tail);
|
||||
if (cached && panes.includes(cached)) return capture(cached);
|
||||
for (const p of panes.filter(Boolean)) {
|
||||
const text = capture(p);
|
||||
if (text.includes(tail)) {
|
||||
paneByTail.set(tail, p);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
throw new Error(`no pane showing workdir ${tail}; panes: ${panes.join(', ')}`);
|
||||
}
|
||||
|
||||
async function createCodexSession(workingDir: string): Promise<string> {
|
||||
const created = await api('POST', '/api/sessions', {
|
||||
mode: 'codex',
|
||||
workingDir,
|
||||
envOverrides: { CODEX_HOME: codexHome },
|
||||
});
|
||||
// Tolerate both the ApiResponse envelope and the legacy raw shape
|
||||
const payload = created.data ?? created;
|
||||
const id = payload.session?.id ?? payload.id ?? payload.sessionId;
|
||||
expect(id, JSON.stringify(created).slice(0, 300)).toBeTruthy();
|
||||
createdSessions.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Select the session in the UI and wait for the codex composer to render. */
|
||||
async function openInBrowser(id: string): Promise<void> {
|
||||
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const app = (window as any).app;
|
||||
const buf = app.terminal?.buffer.active;
|
||||
if (!buf) return false;
|
||||
for (let y = 0; y < app.terminal.rows; y++) {
|
||||
const t = buf.getLine(buf.baseY + y)?.translateToString(true) ?? '';
|
||||
if (/^› /.test(t)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
|
||||
// Wait for the exact typing precondition: the CURSOR parked on the composer
|
||||
// row (the predictWhen gate itself), not merely a composer row existing —
|
||||
// during the boot animation the cursor roams and predictions are suppressed.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as any;
|
||||
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
function predictState(): Promise<{ outstanding: number; confirmedTotal: number; droppedTotal: number }> {
|
||||
return page.evaluate(() => (window as any).app._predictiveEcho.state);
|
||||
}
|
||||
|
||||
function spanCount(): Promise<number> {
|
||||
return page.evaluate(() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length);
|
||||
}
|
||||
|
||||
/** Deterministic composer reset: codex's Ctrl+U kills only to LINE START, so
|
||||
* End first; then a settle. Leaves the cursor parked on an empty composer. */
|
||||
async function resetComposer(): Promise<void> {
|
||||
// Codex can be mid-respawn after an exhausted 401 retry loop killed it:
|
||||
// wait for a live composer (the gate itself) before touching the keyboard.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as any;
|
||||
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 25000 }
|
||||
);
|
||||
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Control+u');
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
|
||||
/** After a submit 401s, codex sits in a Reconnecting retry loop that can eat
|
||||
* typed input; Esc interrupts it. Wait until the retry line is gone. */
|
||||
async function cancelRetryLoop(): Promise<void> {
|
||||
const deadline = Date.now() + 15000;
|
||||
while (Date.now() < deadline) {
|
||||
const pane = capturePaneFor(trustedWorkdir);
|
||||
if (!/Reconnecting|esc to interrupt/.test(pane)) break;
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
// Settle to a live composer (codex may have died at 5/5 and respawned),
|
||||
// then require it to STAY alive: the fake-key request can kill codex
|
||||
// seconds later, so a single gate-true observation is not enough.
|
||||
const stableDeadline = Date.now() + 30000;
|
||||
for (;;) {
|
||||
await resetComposer();
|
||||
let stable = true;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const alive = await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
|
||||
});
|
||||
if (!alive) {
|
||||
stable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stable || Date.now() > stableDeadline) return;
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_AVAILABLE = hasCodex();
|
||||
const d = CODEX_AVAILABLE ? describe : describe.skip;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!CODEX_AVAILABLE) return;
|
||||
codexHome = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-'));
|
||||
trustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-work-'));
|
||||
untrustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-untrusted-'));
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
writeFileSync(resolve(codexHome, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: 'sk-test-123' }));
|
||||
writeFileSync(resolve(codexHome, 'config.toml'), `[projects."${trustedWorkdir}"]\ntrust_level = "trusted"\n`);
|
||||
|
||||
// Child env: strip the vitest markers so the lab server runs REAL tmux/codex
|
||||
const env: Record<string, string | undefined> = { ...process.env };
|
||||
delete env.VITEST;
|
||||
delete env.VITEST_MODE;
|
||||
delete env.VITEST_POOL_ID;
|
||||
delete env.VITEST_WORKER_ID;
|
||||
delete env.NODE_ENV;
|
||||
env.CODEMAN_INSTANCE = INSTANCE;
|
||||
env.PATH = `${CODEX_BIN_DIR}:${env.PATH}`;
|
||||
|
||||
server = spawn('npx', ['tsx', 'src/index.ts', 'web', '--port', String(PORT)], {
|
||||
cwd: ROOT,
|
||||
env: env as NodeJS.ProcessEnv,
|
||||
stdio: 'ignore',
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// Wait for the lab server
|
||||
const deadline = Date.now() + 30000;
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/status`);
|
||||
if (res.ok) break;
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error('lab server did not start on :3222');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify({ localEchoEnabled: true }));
|
||||
});
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10000 });
|
||||
|
||||
sessionId = await createCodexSession(trustedWorkdir);
|
||||
await openInBrowser(sessionId);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdSessions) {
|
||||
try {
|
||||
await api('DELETE', `/api/sessions/${id}`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
await context?.close();
|
||||
await browser?.close();
|
||||
server?.kill('SIGTERM');
|
||||
try {
|
||||
execSync(`${TMUX} kill-server`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
d('codex predictive echo E2E (real codex TUI)', () => {
|
||||
it('bundle smoke: both echo globals are defined', async () => {
|
||||
const globals = await page.evaluate(() => ({
|
||||
zerolag: typeof (window as any).LocalEchoOverlay,
|
||||
predictive: typeof (window as any).PredictiveEchoOverlay,
|
||||
instance: !!(window as any).app._predictiveEcho,
|
||||
}));
|
||||
expect(globals.zerolag).toBe('function');
|
||||
expect(globals.predictive).toBe('function');
|
||||
expect(globals.instance).toBe(true);
|
||||
});
|
||||
|
||||
it('typing predicts every char and converges into the real echo', async () => {
|
||||
await page.keyboard.type('hello', { delay: 30 });
|
||||
// Keystrokes went through the predictor (a transient repaint may suppress
|
||||
// one or two: that is designed graceful degradation, never a ghost)
|
||||
await page.waitForFunction(() => {
|
||||
const s = (window as any).app._predictiveEcho.state;
|
||||
return s.confirmedTotal + s.outstanding >= 3;
|
||||
});
|
||||
// Convergence: spans gone, composer shows the text, ZERO mispredictions
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0
|
||||
);
|
||||
const s = await predictState();
|
||||
expect(s.confirmedTotal).toBeGreaterThanOrEqual(3);
|
||||
expect(s.droppedTotal).toBe(0);
|
||||
const deadline = Date.now() + 10000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
pane = capturePaneFor(trustedWorkdir);
|
||||
if (pane.includes('hello')) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(pane).toContain('hello');
|
||||
}, 30_000);
|
||||
|
||||
it('#222: slash picker filters live while predictions confirm away', async () => {
|
||||
await resetComposer();
|
||||
await page.keyboard.type('/', { delay: 30 });
|
||||
const deadline = Date.now() + 10000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
pane = capturePaneFor(trustedWorkdir);
|
||||
if (/\/model|\/skills|\/init/.test(pane)) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(pane).toMatch(/\/model|\/skills|\/init/);
|
||||
await page.keyboard.type('mo', { delay: 40 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0
|
||||
);
|
||||
const filtered = capturePaneFor(trustedWorkdir);
|
||||
expect(filtered).toContain('/model');
|
||||
await page.keyboard.press('Escape'); // close the picker
|
||||
await page.keyboard.press('Control+u'); // deterministically empty the composer
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const s = await predictState();
|
||||
expect(s.outstanding).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
it('#219: typed text + paste land in order with nothing dropped', async () => {
|
||||
await resetComposer();
|
||||
const pidBefore = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim();
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
(window as any).__dbg = [];
|
||||
if (!(window as any).__origSend2) (window as any).__origSend2 = app._sendInputAsync.bind(app);
|
||||
app._sendInputAsync = (sid: string, data: string, opts?: unknown) => {
|
||||
(window as any).__dbg.push([sid.slice(0, 8), JSON.stringify(data)]);
|
||||
return (window as any).__origSend2(sid, data, opts);
|
||||
};
|
||||
});
|
||||
await page.keyboard.type('abc', { delay: 30 });
|
||||
await page.evaluate(() => (window as any).app.terminal.paste('XYZ'));
|
||||
const deadline = Date.now() + 15000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
pane = capturePaneFor(trustedWorkdir);
|
||||
if (pane.includes('abcXYZ')) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
if (!pane.includes('abcXYZ')) {
|
||||
const pidAfter = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim();
|
||||
const dbg = await page.evaluate(() => (window as any).__dbg);
|
||||
const row = await page.evaluate(() => {
|
||||
const b = (window as any).app.terminal.buffer.active;
|
||||
return b.getLine(b.baseY + b.cursorY)?.translateToString(true);
|
||||
});
|
||||
console.log(
|
||||
'DBG219 pids-before:',
|
||||
pidBefore,
|
||||
'| pids-after:',
|
||||
pidAfter,
|
||||
'| sends:',
|
||||
JSON.stringify(dbg),
|
||||
'| app-cursor-row:',
|
||||
JSON.stringify(row)
|
||||
);
|
||||
}
|
||||
expect(pane).toContain('abcXYZ');
|
||||
expect(await spanCount()).toBe(0); // paste classified 'clear'
|
||||
await page.keyboard.press('Control+u'); // Ctrl+U: clear the composer line
|
||||
}, 30_000);
|
||||
|
||||
it('#220: wrapped input renders exactly, no ghost glyphs past the edge', async () => {
|
||||
await resetComposer();
|
||||
const long = 'the quick brown fox jumps over the lazy dog and keeps running until the composer has to wrap';
|
||||
await page.keyboard.type(long, { delay: 5 });
|
||||
// Predictions on the first composer row confirm or drop; continuation
|
||||
// rows are gate-suppressed. Everything converges:
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
|
||||
undefined,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
const deadline = Date.now() + 10000;
|
||||
let ok = false;
|
||||
while (Date.now() < deadline) {
|
||||
const pane = capturePaneFor(trustedWorkdir).replace(/\s+/g, ' ');
|
||||
if (pane.includes('composer has to wrap')) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(ok).toBe(true);
|
||||
await page.keyboard.press('Control+u');
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}, 45_000);
|
||||
|
||||
it('modal ghost eliminator: zero spans while typing on the trust dialog', async () => {
|
||||
const modalSession = await createCodexSession(untrustedWorkdir);
|
||||
try {
|
||||
await page.evaluate((sid) => (window as any).app.selectSession(sid), modalSession);
|
||||
// Wait for the trust dialog (the pane may not exist for the first second)
|
||||
const deadline = Date.now() + 25000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
pane = capturePaneFor(untrustedWorkdir);
|
||||
if (pane.includes('Press enter to continue')) break;
|
||||
} catch {
|
||||
/* session still spawning */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
expect(pane).toContain('Press enter to continue');
|
||||
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
|
||||
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||
await page.keyboard.type('x', { delay: 30 });
|
||||
expect(await spanCount()).toBe(0); // gate rejected: no ghost on the modal
|
||||
expect((await predictState()).outstanding).toBe(0);
|
||||
} finally {
|
||||
// Never strand later tests on the modal session
|
||||
await api('DELETE', `/api/sessions/${modalSession}`);
|
||||
await page.evaluate((sid) => (window as any).app.selectSession(sid), sessionId);
|
||||
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
|
||||
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it('kill switch: localEchoEnabled OFF clears spans and typing still streams', async () => {
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
const s = app.loadAppSettingsFromStorage();
|
||||
s.localEchoEnabled = false;
|
||||
app.saveAppSettingsToStorage(s);
|
||||
app._updateLocalEchoState();
|
||||
});
|
||||
expect(await page.evaluate(() => (window as any).app._localEchoPolicy)).toBe('off');
|
||||
expect(await spanCount()).toBe(0);
|
||||
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Control+u');
|
||||
await page.keyboard.type('still-live', { delay: 20 });
|
||||
const deadline = Date.now() + 10000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
pane = capturePaneFor(trustedWorkdir);
|
||||
if (pane.includes('still-live')) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(pane).toContain('still-live'); // 1.12.2 behavior exactly
|
||||
expect(await spanCount()).toBe(0);
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
const s = app.loadAppSettingsFromStorage();
|
||||
s.localEchoEnabled = true;
|
||||
app.saveAppSettingsToStorage(s);
|
||||
app._updateLocalEchoState();
|
||||
});
|
||||
await page.keyboard.press('Control+u');
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}, 30_000);
|
||||
|
||||
it('byte-identity: the wire receives the same bytes with the predictor active vs absent', async () => {
|
||||
const script = async () => {
|
||||
await resetComposer();
|
||||
await page.keyboard.type('ab', { delay: 60 });
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.type('c', { delay: 60 });
|
||||
await page.evaluate(() => (window as any).app.terminal.paste('PQ'));
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Control+u');
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
};
|
||||
|
||||
const record = () =>
|
||||
page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
(window as any).__trace = [];
|
||||
if (!(window as any).__origSend) (window as any).__origSend = app._sendInputAsync.bind(app);
|
||||
app._sendInputAsync = (sid: string, data: string, opts?: unknown) => {
|
||||
(window as any).__trace.push(data);
|
||||
return (window as any).__origSend(sid, data, opts);
|
||||
};
|
||||
});
|
||||
const trace = () => page.evaluate(() => ((window as any).__trace as string[]).join(''));
|
||||
|
||||
await record();
|
||||
await script();
|
||||
const withPredictor = await trace();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__savedPredictor = (window as any).app._predictiveEcho;
|
||||
(window as any).app._predictiveEcho = null;
|
||||
});
|
||||
await record();
|
||||
await script();
|
||||
const withoutPredictor = await trace();
|
||||
await page.evaluate(() => {
|
||||
(window as any).app._predictiveEcho = (window as any).__savedPredictor;
|
||||
});
|
||||
|
||||
expect(withPredictor.length).toBeGreaterThan(0);
|
||||
expect(withoutPredictor).toBe(withPredictor); // the visual-only invariant, end to end
|
||||
}, 60_000);
|
||||
|
||||
it('#218: arrows + mid-word insert submit the exact edited text', async () => {
|
||||
await resetComposer();
|
||||
await page.keyboard.type('hello', { delay: 30 });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
// Nav keys clear predictions immediately (classify 'clear')
|
||||
expect(await predictState()).toMatchObject({ outstanding: 0 });
|
||||
expect(await spanCount()).toBe(0);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.type('X', { delay: 30 });
|
||||
await page.keyboard.press('Enter');
|
||||
// The submitted transcript line carries the edited text
|
||||
const deadline = Date.now() + 15000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
pane = capturePaneFor(trustedWorkdir);
|
||||
if (pane.includes('helXlo')) break;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
expect(pane).toContain('helXlo');
|
||||
expect(await spanCount()).toBe(0);
|
||||
await cancelRetryLoop();
|
||||
}, 45_000);
|
||||
|
||||
it('simulated 300ms RTT: instant spans with correct pixel geometry, exact convergence', async () => {
|
||||
await resetComposer();
|
||||
// Delay every terminal.write chunk by 300ms: display-side injection only,
|
||||
// the wire is untouched. This is the condition the feature exists for.
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
(window as any).__origWrite = app.terminal.write.bind(app.terminal);
|
||||
app.terminal.write = (data: unknown, cb?: () => void) =>
|
||||
setTimeout(() => (window as any).__origWrite(data, cb), 300);
|
||||
});
|
||||
// Let any pre-wrapper chunks and their reconcile passes settle first
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
const base = await predictState();
|
||||
|
||||
await page.keyboard.press('h');
|
||||
// The span exists NOW, long before the delayed echo can land
|
||||
const snap = await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
const span = document.querySelector('.xterm-screen [data-predictive-echo] span') as HTMLElement;
|
||||
const screen = document.querySelector('.xterm-screen') as HTMLElement;
|
||||
const dims = app.terminal._core._renderService.dimensions.css.cell;
|
||||
const buf = app.terminal.buffer.active;
|
||||
return span
|
||||
? {
|
||||
outstanding: app._predictiveEcho.state.outstanding,
|
||||
spanLeft: span.getBoundingClientRect().left - screen.getBoundingClientRect().left,
|
||||
spanTop: span.getBoundingClientRect().top - screen.getBoundingClientRect().top,
|
||||
expectedLeft: buf.cursorX * dims.width,
|
||||
expectedTop: buf.cursorY * dims.height,
|
||||
text: span.textContent,
|
||||
}
|
||||
: null;
|
||||
});
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap!.outstanding).toBe(1);
|
||||
expect(snap!.text).toBe('h');
|
||||
// Pixel geometry: the DOM span sits on the exact cell the echo will use
|
||||
expect(Math.abs(snap!.spanLeft - snap!.expectedLeft)).toBeLessThan(1.5);
|
||||
expect(Math.abs(snap!.spanTop - snap!.expectedTop)).toBeLessThan(1.5);
|
||||
|
||||
await page.keyboard.type('igh rtt', { delay: 40 });
|
||||
// (No mid-flight outstanding assertion: with local codex the delayed echo
|
||||
// begins confirming DURING the typing. The snap above already pinned the
|
||||
// zero-lag property; convergence + the delta below pin the rest.)
|
||||
|
||||
// Full convergence after the delayed echo lands
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
|
||||
undefined,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
const deadline = Date.now() + 10000;
|
||||
let pane = '';
|
||||
while (Date.now() < deadline) {
|
||||
pane = capturePaneFor(trustedWorkdir);
|
||||
if (pane.includes('high rtt')) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(pane).toContain('high rtt');
|
||||
const after = await predictState();
|
||||
expect(after.confirmedTotal - base.confirmedTotal).toBeGreaterThanOrEqual(6); // h + igh rtt
|
||||
|
||||
// #218 under RTT: arrows still edit correctly with the delayed display
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.type('Z', { delay: 40 });
|
||||
await page.keyboard.press('Enter');
|
||||
const deadline2 = Date.now() + 15000;
|
||||
let pane2 = '';
|
||||
while (Date.now() < deadline2) {
|
||||
pane2 = capturePaneFor(trustedWorkdir);
|
||||
if (pane2.includes('high rtZt')) break;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
expect(pane2).toContain('high rtZt');
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.terminal.write = (window as any).__origWrite;
|
||||
});
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for the pure halves of daemon-control (issue #231): argv rebuilding,
|
||||
* the readiness URL, pidfile parsing, the stale-pid identity check, and the
|
||||
* `/api/status` probe against a real socket.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
buildWebArgs,
|
||||
isProcessAlive,
|
||||
looksLikeCodemanWeb,
|
||||
parsePidFileContents,
|
||||
probeServer,
|
||||
} from '../src/daemon-control.js';
|
||||
|
||||
const PORT = 3216;
|
||||
|
||||
describe('buildWebArgs', () => {
|
||||
it('always passes host and port through explicitly', () => {
|
||||
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'3000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards every optional flag it was given', () => {
|
||||
const args = buildWebArgs({
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
https: true,
|
||||
titleHostname: 'tower',
|
||||
allowUnauthenticatedNetwork: true,
|
||||
multiuser: true,
|
||||
});
|
||||
expect(args).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8080',
|
||||
'--https',
|
||||
'--title-hostname',
|
||||
'tower',
|
||||
'--allow-unauthenticated-network',
|
||||
'--multiuser',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never re-emits the daemon flags themselves (the child must not re-fork)', () => {
|
||||
const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false });
|
||||
expect(args).not.toContain('--daemon');
|
||||
expect(args).not.toContain('-d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it('is the address a browser can open, with no path on it', () => {
|
||||
expect(buildBaseUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000');
|
||||
expect(buildBaseUrl({ host: '0.0.0.0', port: 8443, https: true })).toBe('https://127.0.0.1:8443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStatusUrl', () => {
|
||||
it('uses http by default and https when asked', () => {
|
||||
expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: true })).toBe('https://127.0.0.1:3000/api/status');
|
||||
});
|
||||
|
||||
it('rewrites wildcard binds to loopback, since they are not connectable', () => {
|
||||
expect(buildStatusUrl({ host: '0.0.0.0', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '::', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
});
|
||||
|
||||
it('brackets a bare IPv6 literal', () => {
|
||||
expect(buildStatusUrl({ host: '::1', port: 3000, https: false })).toBe('http://[::1]:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '[::1]', port: 3000, https: false })).toBe('http://[::1]:3000/api/status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePidFileContents', () => {
|
||||
it('accepts a plain pid with surrounding whitespace', () => {
|
||||
expect(parsePidFileContents('4242\n')).toBe(4242);
|
||||
expect(parsePidFileContents(' 4242 ')).toBe(4242);
|
||||
});
|
||||
|
||||
it('rejects garbage, empties and floats', () => {
|
||||
expect(parsePidFileContents('')).toBeNull();
|
||||
expect(parsePidFileContents('not a pid')).toBeNull();
|
||||
expect(parsePidFileContents('42.5')).toBeNull();
|
||||
expect(parsePidFileContents('-42')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects pid 0 and pid 1: neither is ever our server', () => {
|
||||
expect(parsePidFileContents('0')).toBeNull();
|
||||
expect(parsePidFileContents('1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeCodemanWeb', () => {
|
||||
it('matches the ways the server is actually launched', () => {
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node /home/u/.codeman/app/dist/index.js web')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js web --https')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('node /repo/src/index.ts web --port 3000')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('/opt/homebrew/bin/codeman web')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('aicodeman web --host 0.0.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects anything that inherited a recycled pid', () => {
|
||||
expect(looksLikeCodemanWeb(null)).toBe(false);
|
||||
expect(looksLikeCodemanWeb('')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js session list')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('vim web')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('/usr/lib/systemd/systemd --user')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('sees this very process', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not see an unused high pid', () => {
|
||||
// 2^22 is above the default pid_max on Linux and macOS.
|
||||
expect(isProcessAlive(4_194_303)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeServer', () => {
|
||||
let server: http.Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.url === '/unauthorized') {
|
||||
res.writeHead(401).end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
if (req.url === '/foreign') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end('<html>some other app</html>');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('reports up and reads the version back', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
|
||||
expect(result.up).toBe(true);
|
||||
expect(result.version).toBe('9.9.9');
|
||||
});
|
||||
|
||||
it('counts a 401 as up, because auth being active proves a server is there', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
|
||||
expect(result.up).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down when nothing is listening', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down for a malformed url instead of throwing', async () => {
|
||||
const result = await probeServer('not-a-url');
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -127,12 +127,31 @@ describe('refreshStaleCodemanHooks', () => {
|
||||
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V');
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh');
|
||||
expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse]));
|
||||
expect(after.hooks.CustomEvent).toEqual(customEvent);
|
||||
});
|
||||
|
||||
// A case can be current on the secret AND the background-wake hook and still carry
|
||||
// the `-k`-less curl shape, which exits 60 against a self-signed HTTPS API and is
|
||||
// swallowed by `|| true` — every hook event dead, silently. The refresh must treat
|
||||
// that as a third stale shape.
|
||||
it('heals a current-looking block whose hook curls lack -k (HTTPS self-signed installs)', async () => {
|
||||
const { generateHooksConfig } = await import('../src/hooks-config.js');
|
||||
const flagless = JSON.parse(JSON.stringify(generateHooksConfig()).replaceAll('curl -sk ', 'curl -s '));
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: flagless.hooks }, null, 2));
|
||||
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
|
||||
const after = readFileSync(settingsPath, 'utf-8');
|
||||
expect(after).toContain('curl -sk -X POST');
|
||||
expect(after).not.toContain('curl -s -X POST');
|
||||
// and the pass is convergent: a second refresh must not rewrite
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(after);
|
||||
});
|
||||
|
||||
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
|
||||
+283
-3
@@ -6,13 +6,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
ensureCodemanHooks,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
generateSubagentStopGuardScript,
|
||||
refreshStaleCodemanHooks,
|
||||
writeHooksConfig,
|
||||
} from '../src/hooks-config.js';
|
||||
@@ -35,6 +37,20 @@ describe('generateHooksConfig', () => {
|
||||
expect(config.hooks.Stop).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should guard subagent stops while their background work is active', () => {
|
||||
const config = generateHooksConfig();
|
||||
const subagentHooks = config.hooks.SubagentStop as Array<{
|
||||
hooks: Array<{ type: string; command: string; args: string[]; timeout: number }>;
|
||||
}>;
|
||||
|
||||
expect(subagentHooks).toHaveLength(1);
|
||||
expect(subagentHooks[0].hooks[0]).toMatchObject({
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', generateSubagentStopGuardScript()],
|
||||
});
|
||||
});
|
||||
|
||||
it('should configure a self-contained Bash background-task rewake hook', () => {
|
||||
const config = generateHooksConfig();
|
||||
const postToolHooks = config.hooks.PostToolUse as Array<{
|
||||
@@ -95,6 +111,17 @@ describe('generateHooksConfig', () => {
|
||||
expect(notifHooks[0].hooks[0].command).toContain('|| true');
|
||||
});
|
||||
|
||||
// On --https/tailscale installs CODEMAN_API_URL is HTTPS with a self-signed cert.
|
||||
// A `-k`-less hook curl exits 60 there, the `|| true` swallows it, and every hook
|
||||
// event (stop, permission_prompt, elicitation_dialog, idle_prompt, teammate_idle,
|
||||
// task_completed) dies silently — killing respawn's idle signals and the wait
|
||||
// endpoints' stop/blocked. The statusline exporter always carried -k; the hooks must too.
|
||||
it('every hook curl tolerates a self-signed HTTPS API (curl -sk)', () => {
|
||||
const serialized = JSON.stringify(generateHooksConfig());
|
||||
expect(serialized).toContain('curl -sk -X POST');
|
||||
expect(serialized).not.toContain('curl -s -X POST');
|
||||
});
|
||||
|
||||
it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>;
|
||||
@@ -199,7 +226,8 @@ describe('writeHooksConfig', () => {
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V');
|
||||
expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(JSON.stringify(parsed.hooks.SubagentStop)).toContain('CODEMAN_SUBAGENT_STOP_GUARD_V1');
|
||||
});
|
||||
|
||||
it('should replace an older rewake script version without duplicating it', async () => {
|
||||
@@ -231,10 +259,29 @@ describe('writeHooksConfig', () => {
|
||||
const serialized = JSON.stringify(parsed.hooks.PostToolUse);
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(parsed.hooks.PostToolUse[0].hooks).toHaveLength(1);
|
||||
expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V2');
|
||||
expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1');
|
||||
});
|
||||
|
||||
it('replaces the V2 background hook without duplicating it', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const oldSettings = JSON.stringify({ hooks: generateHooksConfig().hooks }, null, 2).replaceAll(
|
||||
'CODEMAN_BACKGROUND_REWAKE_V3',
|
||||
'CODEMAN_BACKGROUND_REWAKE_V2'
|
||||
);
|
||||
writeFileSync(settingsPath, oldSettings);
|
||||
|
||||
await refreshStaleCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const postToolUse = JSON.stringify(parsed.hooks.PostToolUse);
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(postToolUse).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(postToolUse).not.toContain('CODEMAN_BACKGROUND_REWAKE_V2');
|
||||
});
|
||||
|
||||
it('should not add rewake hooks to a user-owned hook configuration', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
@@ -267,6 +314,35 @@ describe('writeHooksConfig', () => {
|
||||
expect(parsed.hooks.Notification).toBeDefined();
|
||||
});
|
||||
|
||||
it('should safely add Codeman hooks to an existing managed-case settings file', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const userHooks = {
|
||||
PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: './format.sh' }] }],
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks, permissions: { allow: ['Read'] } }, null, 2));
|
||||
|
||||
await ensureCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.permissions).toEqual({ allow: ['Read'] });
|
||||
expect(parsed.hooks.PostToolUse).toEqual(expect.arrayContaining(userHooks.PostToolUse));
|
||||
expect(JSON.stringify(parsed.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(JSON.stringify(parsed.hooks)).toContain('CODEMAN_SUBAGENT_STOP_GUARD_V1');
|
||||
});
|
||||
|
||||
it('should not replace a malformed managed-case settings file', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(settingsPath, '{ malformed');
|
||||
|
||||
await ensureCodemanHooks(testDir);
|
||||
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ malformed');
|
||||
});
|
||||
|
||||
it('should handle malformed existing settings.local.json', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
@@ -359,6 +435,210 @@ describe('background task rewake helper', () => {
|
||||
expect(result.stderr).toContain('completed');
|
||||
expect(result.stderr).toContain('/tmp/bg-test-1.output');
|
||||
});
|
||||
|
||||
it('rewakes a subagent when Claude queues completion in the parent transcript', async () => {
|
||||
const sessionId = '7148e9de-7673-48b8-bf38-6799e52c346a';
|
||||
const sessionDir = join(testDir, sessionId);
|
||||
const subagentDir = join(sessionDir, 'subagents');
|
||||
const parentTranscriptPath = `${sessionDir}.jsonl`;
|
||||
const subagentTranscriptPath = join(subagentDir, 'agent-afacts-class2.jsonl');
|
||||
mkdirSync(subagentDir, { recursive: true });
|
||||
writeFileSync(parentTranscriptPath, '');
|
||||
writeFileSync(subagentTranscriptPath, '');
|
||||
|
||||
const resultPromise = runHelper({
|
||||
session_id: sessionId,
|
||||
agent_id: 'afacts-class2',
|
||||
transcript_path: subagentTranscriptPath,
|
||||
tool_response: {
|
||||
backgroundTaskId: 'bg-subagent-1',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(
|
||||
parentTranscriptPath,
|
||||
JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
content:
|
||||
'<task-notification>\n<task-id>bg-subagent-1</task-id>\n<status>completed</status>\n' +
|
||||
'<output-file>/tmp/bg-subagent-1.output</output-file>\n</task-notification>',
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain('bg-subagent-1');
|
||||
expect(result.stderr).toContain('/tmp/bg-subagent-1.output');
|
||||
});
|
||||
|
||||
it('includes a marked background report in the wake feedback', async () => {
|
||||
const transcriptPath = join(testDir, 'transcript.jsonl');
|
||||
const tasksDir = join(testDir, 'tasks');
|
||||
const outputPath = join(tasksDir, 'bg-report-1.output');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(transcriptPath, '');
|
||||
writeFileSync(
|
||||
outputPath,
|
||||
[
|
||||
'launcher output',
|
||||
'=== CODEMAN_RESULT_BEGIN ===',
|
||||
'Summary line',
|
||||
'Detail after the old 30-line preview boundary',
|
||||
'=== CODEMAN_RESULT_END ===',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const resultPromise = runHelper({
|
||||
transcript_path: transcriptPath,
|
||||
tool_response: {
|
||||
stdout: `Command running in background with ID: bg-report-1. Output is being written to: ${outputPath}.`,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
content:
|
||||
'<task-notification>\n<task-id>bg-report-1</task-id>\n<status>completed</status>\n' +
|
||||
`<output-file>${outputPath}</output-file>\n</task-notification>`,
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain('<codeman-background-result>');
|
||||
expect(result.stderr).toContain('Summary line');
|
||||
expect(result.stderr).toContain('Detail after the old 30-line preview boundary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subagent stop guard helper', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-subagent-stop-guard-test-' + Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runGuard(transcriptLines: unknown[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
const transcriptPath = join(testDir, 'agent-test.jsonl');
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['-e', generateSubagentStopGuardScript()], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(JSON.stringify({ agent_transcript_path: transcriptPath }));
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveTask<T>(taskId: string, action: () => Promise<T>): Promise<T> {
|
||||
const tasksDir = join(testDir, 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
const outputFd = openSync(join(tasksDir, `${taskId}.output`), 'a');
|
||||
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 10000)'], {
|
||||
stdio: ['ignore', outputFd, outputFd],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
closeSync(outputFd);
|
||||
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
const closed = new Promise<void>((resolve) => child.once('close', () => resolve()));
|
||||
child.kill();
|
||||
await closed;
|
||||
}
|
||||
}
|
||||
|
||||
const monitorResult = (taskId: string) => ({
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: `Monitor started (task ${taskId}, pid 123).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const completion = (taskId: string) => ({
|
||||
type: 'user',
|
||||
message: {
|
||||
content:
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n` + '<status>completed</status>\n</task-notification>',
|
||||
},
|
||||
});
|
||||
|
||||
it('blocks an intermediate subagent stop while a sibling monitor is active', async () => {
|
||||
const result = await withLiveTask('monitor-still-live', () =>
|
||||
runGuard([monitorResult('monitor-first'), monitorResult('monitor-still-live'), completion('monitor-first')])
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ decision: 'block' });
|
||||
expect(result.stdout).toContain('monitor-still-live');
|
||||
expect(result.stdout).not.toContain('monitor-first,');
|
||||
});
|
||||
|
||||
it('allows a subagent to stop after all of its monitored work finishes', async () => {
|
||||
const result = await runGuard([
|
||||
monitorResult('monitor-first'),
|
||||
monitorResult('monitor-second'),
|
||||
completion('monitor-first'),
|
||||
completion('monitor-second'),
|
||||
]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('also recognizes background Bash task ownership', async () => {
|
||||
const result = await withLiveTask('bash-live-1', () =>
|
||||
runGuard([
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: 'Command running in background with ID: bash-live-1. Output is being written to a task file.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ decision: 'block' });
|
||||
expect(result.stdout).toContain('bash-live-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Hook Event API Integration Tests ==========
|
||||
|
||||
@@ -89,4 +89,97 @@ describe('Stable HTTP contract (live server)', () => {
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
/**
|
||||
* The agent wait primitives, through the REAL pipeline.
|
||||
*
|
||||
* Their own route tests hand-roll a partial copy of the preSerialization hook that
|
||||
* maps errorCode to status but does NOT wrap bare payloads — so nothing there
|
||||
* proves these routes emit a correct envelope, a correct status, or work through
|
||||
* the /api/v1 alias, and one assertion in them pins `{}` for a response no client
|
||||
* will ever receive. This is the file whose docstring already claims that scope.
|
||||
*/
|
||||
describe('agent wait primitives', () => {
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await fetch(`${base}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
sessionId = (await res.json()).data.session.id;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fetch(`${base}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('answers a wait timeout as a 200 inside the envelope, on the /api/v1 alias', async () => {
|
||||
// A timeout is the long-poll SUCCEEDING at "did this happen within N ms?"; a
|
||||
// 4xx/5xx here would make every poll boundary indistinguishable from a failure.
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=working&timeout=1000`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('cache-control')).toBe('no-store');
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessionId).toBe(sessionId);
|
||||
// The one shape all three wait endpoints share.
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.signal).toBeNull();
|
||||
expect(body.data.wait.timeoutMs).toBe(1000);
|
||||
expect(body.data.wait.until).toEqual(['working']);
|
||||
});
|
||||
|
||||
it('returns a contract-shaped 400 for an unknown until token', async () => {
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=stpo`);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toContain('stpo');
|
||||
});
|
||||
|
||||
it('returns a contract-shaped 400 naming the bad query parameter', async () => {
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?timeout=30s`);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toContain('timeout');
|
||||
});
|
||||
|
||||
it('wraps the non-wait input response as { success: true, data: {} }', async () => {
|
||||
// What a client actually receives on the fire-and-forget path — NOT the bare
|
||||
// `{}` the handler returns and the route tests assert.
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'hello' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ success: true, data: {} });
|
||||
});
|
||||
|
||||
it('serves wait-output through the same envelope', async () => {
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait-output?match=NEVER_APPEARS&timeout=1000`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.matched).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.match).toBe('NEVER_APPEARS');
|
||||
});
|
||||
|
||||
it('404s an unknown session on both new routes, with the error envelope', async () => {
|
||||
for (const path of ['wait?until=idle', 'wait-output?match=x']) {
|
||||
const res = await fetch(`${base}/api/v1/sessions/nonexistent/${path}`);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* @fileoverview Local-echo gating and input-ordering helpers for codex
|
||||
* sessions (issues #218/#219/#220/#222).
|
||||
*
|
||||
* Codex's composer is interactive per keystroke: typing "/" pops a
|
||||
* live-filtering command picker (#222), the composer grows as it wraps
|
||||
* (#220), pastes arrive bracketed (#219) and arrows edit server-side state
|
||||
* (#218). The buffer-until-Enter local echo overlay starves all of that, so
|
||||
* codex-mode sessions must use plain PTY echo like shell. The shared overlay
|
||||
* branch (claude/gemini/opencode) additionally flushes typed-but-unsent text
|
||||
* before forwarding bracketed pastes and composer nav keys, and hands the
|
||||
* session to pass-through after a nav key.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom), mirroring
|
||||
* test/input-send-order.test.ts. End-to-end behavior was verified against a
|
||||
* real codex 0.147.0 TUI in tmux through a headless browser.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type OverlayStub = {
|
||||
pendingText: string;
|
||||
cleared: number;
|
||||
suppressed: number;
|
||||
prompts: unknown[];
|
||||
clear(): void;
|
||||
suppressBufferDetection(): void;
|
||||
setPrompt(p: unknown): void;
|
||||
appendText: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type AppInstance = {
|
||||
activeSessionId: string | null;
|
||||
sessions: Map<string, { mode: string }>;
|
||||
terminal?: { focus: () => void };
|
||||
_localEchoEnabled?: boolean;
|
||||
_localEchoOverlay?: OverlayStub;
|
||||
_pendingInput: string;
|
||||
_flushedOffsets?: Map<string, number>;
|
||||
_flushedTexts?: Map<string, string>;
|
||||
_echoPassthroughSessions?: Set<string>;
|
||||
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
||||
sendInput: ReturnType<typeof vi.fn>;
|
||||
_updateLocalEchoState(): void;
|
||||
_flushLocalEchoPending(): void;
|
||||
insertTerminalText(text: string): void;
|
||||
};
|
||||
|
||||
function loadContext() {
|
||||
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||
const windowStub: Record<string, unknown> = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: windowStub,
|
||||
MobileDetection: {
|
||||
isTouchDevice: () => true,
|
||||
isHandheldDevice: () => false,
|
||||
getDeviceType: () => 'desktop',
|
||||
},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
return {
|
||||
CodemanApp,
|
||||
terminalInput: (windowStub as { CodemanTerminalInput?: Record<string, unknown> }).CodemanTerminalInput!,
|
||||
};
|
||||
}
|
||||
|
||||
const { CodemanApp, terminalInput } = loadContext();
|
||||
const isComposerNavKey = terminalInput.isComposerNavKey as (data: string) => boolean;
|
||||
|
||||
function makeOverlay(pending = ''): OverlayStub {
|
||||
return {
|
||||
pendingText: pending,
|
||||
cleared: 0,
|
||||
suppressed: 0,
|
||||
prompts: [],
|
||||
clear() {
|
||||
this.cleared++;
|
||||
this.pendingText = '';
|
||||
},
|
||||
suppressBufferDetection() {
|
||||
this.suppressed++;
|
||||
},
|
||||
setPrompt(p: unknown) {
|
||||
this.prompts.push(p);
|
||||
},
|
||||
appendText: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeApp(mode: string, overlay = makeOverlay()): AppInstance {
|
||||
const app = Object.create(CodemanApp.prototype) as AppInstance;
|
||||
app.activeSessionId = 's1';
|
||||
app.sessions = new Map([['s1', { mode }]]);
|
||||
app._localEchoOverlay = overlay;
|
||||
app._pendingInput = '';
|
||||
app._flushedOffsets = new Map([['s1', 3]]);
|
||||
app._flushedTexts = new Map([['s1', 'abc']]);
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: true });
|
||||
app.sendInput = vi.fn().mockResolvedValue(undefined);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('CodemanTerminalInput.isComposerNavKey', () => {
|
||||
it.each([
|
||||
'\x1b[A',
|
||||
'\x1b[B',
|
||||
'\x1b[C',
|
||||
'\x1b[D',
|
||||
'\x1b[H',
|
||||
'\x1b[F',
|
||||
'\x1bOA',
|
||||
'\x1bOD',
|
||||
'\x1bOH',
|
||||
'\x1bOF',
|
||||
'\x1b[1;5C', // Ctrl+Right
|
||||
'\x1b[1;2A', // Shift+Up
|
||||
'\x1b[3~', // Delete
|
||||
'\x1b[3;5~', // Ctrl+Delete
|
||||
'\x1b[5~', // PgUp
|
||||
'\x1b[6~', // PgDn
|
||||
'\x1b[1~', // Home variant
|
||||
'\x1b[4~', // End variant
|
||||
])('classifies %j as a composer nav key', (seq) => {
|
||||
expect(isComposerNavKey(seq)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'\x1b[?1;2c', // DA1 response
|
||||
'\x1b[>0;276;0c', // DA2 response
|
||||
'\x1b[12;34R', // CPR response
|
||||
'\x1b[1;3R', // CPR response (small coords)
|
||||
'\x1b[0n', // DSR response
|
||||
'\x1b[15~', // F5 (function keys stay out)
|
||||
'\x1b[200~hi\x1b[201~', // bracketed paste
|
||||
'\x1b[?u', // kitty keyboard query response
|
||||
'\x1bOP', // F1
|
||||
'\x1b',
|
||||
'a',
|
||||
'abc',
|
||||
'\r',
|
||||
])('does NOT classify %j as a composer nav key', (seq) => {
|
||||
expect(isComposerNavKey(seq)).toBe(false);
|
||||
});
|
||||
|
||||
it('exports the bracketed paste prefix xterm puts on terminal.paste()', () => {
|
||||
expect(terminalInput.BRACKETED_PASTE_START).toBe('\x1b[200~');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_updateLocalEchoState mode gating', () => {
|
||||
it('disables the overlay for codex sessions even with the setting ON (issues #218/#219/#220/#222)', () => {
|
||||
const overlay = makeOverlay('pending');
|
||||
const app = makeApp('codex', overlay);
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoEnabled).toBe(false);
|
||||
expect(overlay.cleared).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('disables the overlay for shell sessions (PTY provides its own echo)', () => {
|
||||
const app = makeApp('shell');
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['claude', 'gemini', 'opencode'])('keeps the overlay enabled for %s sessions', (mode) => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp(mode, overlay);
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoEnabled).toBe(true);
|
||||
expect(overlay.prompts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_flushLocalEchoPending', () => {
|
||||
it('moves pending text into _pendingInput and resets overlay + flushed tracking', () => {
|
||||
const overlay = makeOverlay('hello');
|
||||
const app = makeApp('claude', overlay);
|
||||
app._flushLocalEchoPending();
|
||||
expect(app._pendingInput).toBe('hello');
|
||||
expect(overlay.cleared).toBe(1);
|
||||
expect(overlay.suppressed).toBe(1);
|
||||
expect(app._flushedOffsets!.has('s1')).toBe(false);
|
||||
expect(app._flushedTexts!.has('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('appends nothing when the overlay is empty', () => {
|
||||
const app = makeApp('claude', makeOverlay(''));
|
||||
app._flushLocalEchoPending();
|
||||
expect(app._pendingInput).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertTerminalText pass-through routing', () => {
|
||||
it('appends to the overlay while local echo is buffering', () => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp('claude', overlay);
|
||||
app._localEchoEnabled = true;
|
||||
app.insertTerminalText('path.txt');
|
||||
expect(overlay.appendText).toHaveBeenCalledWith('path.txt');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends directly while the session is in nav-key pass-through', () => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp('claude', overlay);
|
||||
app._localEchoEnabled = true;
|
||||
app._echoPassthroughSessions = new Set(['s1']);
|
||||
app.insertTerminalText('path.txt');
|
||||
expect(app.sendInput).toHaveBeenCalledWith('path.txt');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Predictive write-through echo (codex) ──────────────────────────────────
|
||||
|
||||
type PredictorStub = {
|
||||
predictChar: ReturnType<typeof vi.fn>;
|
||||
predictBackspace: ReturnType<typeof vi.fn>;
|
||||
clearPredictions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type PredictiveApp = AppInstance & {
|
||||
_localEchoPolicy?: string;
|
||||
_predictiveEcho?: PredictorStub | null;
|
||||
_predictHookOnData(data: string): void;
|
||||
};
|
||||
|
||||
function makePredictor(): PredictorStub {
|
||||
return {
|
||||
predictChar: vi.fn().mockReturnValue(true),
|
||||
predictBackspace: vi.fn().mockReturnValue(true),
|
||||
clearPredictions: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const classifyPredictInput = terminalInput.classifyPredictInput as (data: string) => string;
|
||||
const isCodexComposerRow = terminalInput.isCodexComposerRow as (t: unknown) => boolean;
|
||||
|
||||
describe('CodemanTerminalInput.classifyPredictInput', () => {
|
||||
it.each([
|
||||
['a', 'char'],
|
||||
[' ', 'char'],
|
||||
['€', 'char'],
|
||||
['你', 'char'],
|
||||
['😀', 'char'], // single astral codepoint
|
||||
['\x7f', 'backspace'],
|
||||
['\r', 'clear'],
|
||||
['\n', 'clear'],
|
||||
['\t', 'clear'],
|
||||
['\x03', 'clear'], // Ctrl+C
|
||||
['\x15', 'clear'], // Ctrl+U
|
||||
['\x1b', 'clear'], // bare ESC
|
||||
['\x1b[A', 'clear'], // arrow
|
||||
['\x1bOA', 'clear'], // SS3 arrow
|
||||
['\x1b[3~', 'clear'], // Delete
|
||||
['\x1b[200~hi\x1b[201~', 'clear'], // bracketed paste
|
||||
['\x1b[<0;10;5M', 'clear'], // mouse SGR report
|
||||
['abc', 'text'], // plain multi-char paste
|
||||
['👨👩👧', 'text'], // ZWJ emoji cluster
|
||||
['\r\n', 'clear'],
|
||||
])('classifies %j as %s', (data, expected) => {
|
||||
expect(classifyPredictInput(data)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanTerminalInput.isCodexComposerRow', () => {
|
||||
function terminalWithCursorRow(text: string | null) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
baseY: 4,
|
||||
cursorY: 2,
|
||||
getLine: (y: number) => (y === 6 && text !== null ? { translateToString: () => text } : undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
'› ', // empty composer
|
||||
'› Use /skills to list available skills', // placeholder
|
||||
'› hello', // typed text
|
||||
'› /mo', // slash picker filtering
|
||||
])('matches the composer row %j', (row) => {
|
||||
expect(isCodexComposerRow(terminalWithCursorRow(row))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
' Press enter to continue', // trust/approval modal
|
||||
' this line twice over', // wrapped continuation row (2-space indent)
|
||||
'›no-space',
|
||||
'1. Yes, continue',
|
||||
'',
|
||||
])('rejects the non-composer row %j', (row) => {
|
||||
expect(isCodexComposerRow(terminalWithCursorRow(row))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the cursor row baseY-relative (baseY + cursorY)', () => {
|
||||
// terminalWithCursorRow only answers getLine(6) = baseY 4 + cursorY 2;
|
||||
// a viewportY-based read would ask for a different line and get undefined
|
||||
expect(isCodexComposerRow(terminalWithCursorRow('› x'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the row is missing or getLine throws', () => {
|
||||
expect(isCodexComposerRow(terminalWithCursorRow(null))).toBe(false);
|
||||
const hostile = {
|
||||
buffer: {
|
||||
active: {
|
||||
baseY: 0,
|
||||
cursorY: 0,
|
||||
getLine: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(isCodexComposerRow(hostile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_updateLocalEchoState echo policy', () => {
|
||||
it("codex + setting ON -> policy 'predict' while _localEchoEnabled stays false", () => {
|
||||
const app = makeApp('codex') as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoPolicy).toBe('predict');
|
||||
expect(app._localEchoEnabled).toBe(false); // 1.12.2 invariant untouched
|
||||
expect(app._predictiveEcho.clearPredictions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("codex + setting OFF -> policy 'off' and predictions cleared (kill switch)", () => {
|
||||
const app = makeApp('codex') as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoPolicy).toBe('off');
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shell -> policy 'off'", () => {
|
||||
const app = makeApp('shell') as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoPolicy).toBe('off');
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['claude', 'gemini', 'opencode'])("%s -> policy 'buffer' + overlay enabled (existing behavior)", (mode) => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp(mode, overlay) as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoPolicy).toBe('buffer');
|
||||
expect(app._localEchoEnabled).toBe(true);
|
||||
expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared
|
||||
});
|
||||
|
||||
it('no active session -> policy off, no crash without a predictor instance', () => {
|
||||
const app = makeApp('codex') as PredictiveApp;
|
||||
app._predictiveEcho = null;
|
||||
app.activeSessionId = null;
|
||||
expect(() => app._updateLocalEchoState()).not.toThrow();
|
||||
expect(app._localEchoPolicy).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_predictHookOnData (wire neutrality)', () => {
|
||||
function makePredictApp(): PredictiveApp {
|
||||
const app = makeApp('codex') as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState(); // -> 'predict'
|
||||
return app;
|
||||
}
|
||||
|
||||
it('routes char/backspace/clear kinds to the predictor', () => {
|
||||
const app = makePredictApp();
|
||||
app._predictHookOnData('h');
|
||||
expect(app._predictiveEcho!.predictChar).toHaveBeenCalledWith('h');
|
||||
app._predictHookOnData('\x7f');
|
||||
expect(app._predictiveEcho!.predictBackspace).toHaveBeenCalled();
|
||||
app._predictHookOnData('\r');
|
||||
expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("kind 'text' (plain paste, IME commit) clears the run like 'clear'", () => {
|
||||
// Review finding: an IME word-commit changes the composer without a
|
||||
// prediction; new predictions after it would mis-anchor until cascade.
|
||||
const app = makePredictApp();
|
||||
app._predictHookOnData('pasted text');
|
||||
expect(app._predictiveEcho!.predictChar).not.toHaveBeenCalled();
|
||||
expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never touches _pendingInput and never sends (visual-only pin)', () => {
|
||||
const app = makePredictApp();
|
||||
app._pendingInput = 'queued';
|
||||
for (const data of ['h', 'i', '\x7f', '\r', '\x1b[A', 'multi char', '\x1b[200~x\x1b[201~']) {
|
||||
app._predictHookOnData(data);
|
||||
}
|
||||
expect(app._pendingInput).toBe('queued');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is inert under buffer/off policies and without a predictor', () => {
|
||||
const buffered = makeApp('claude') as PredictiveApp;
|
||||
buffered._predictiveEcho = makePredictor();
|
||||
buffered._updateLocalEchoState(); // 'buffer'
|
||||
buffered._predictHookOnData('h');
|
||||
expect(buffered._predictiveEcho.predictChar).not.toHaveBeenCalled();
|
||||
|
||||
const bundleless = makeApp('codex') as PredictiveApp;
|
||||
bundleless._predictiveEcho = null;
|
||||
bundleless._updateLocalEchoState();
|
||||
expect(() => bundleless._predictHookOnData('h')).not.toThrow();
|
||||
});
|
||||
|
||||
it('a throwing predictor cannot break the hook (exception pin)', () => {
|
||||
const app = makePredictApp();
|
||||
app._predictiveEcho!.predictChar.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
app._pendingInput = 'queued';
|
||||
expect(() => app._predictHookOnData('h')).not.toThrow();
|
||||
expect(app._pendingInput).toBe('queued');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertTerminalText under predict policy', () => {
|
||||
it('routes to sendInput (not the overlay) and clears predictions', () => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp('codex', overlay) as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState(); // predict; _localEchoEnabled false
|
||||
app.insertTerminalText('path.txt');
|
||||
expect(app.sendInput).toHaveBeenCalledWith('path.txt');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+14
-6
@@ -16,22 +16,30 @@ Validates Codeman's mobile UI across 136 devices, covering:
|
||||
|
||||
## Quick Start
|
||||
|
||||
⚠️ Go through `npm run test:mobile`, not `npx vitest` directly. The suite serves the
|
||||
page from `src/web/public`, but `npm run build` puts the xterm vendor bundles in
|
||||
`dist/web/public`, so without them every `/vendor/xterm*` request 404s, `Terminal` is
|
||||
never defined and every test touching `app.terminal` fails on a null. The
|
||||
`pretest:mobile` hook (`scripts/prepare-test-vendor.mjs`) is what puts them in place,
|
||||
and npm only fires it for `npm run test:mobile`. Run the prepare script by hand first
|
||||
if you really need a bare `npx vitest`.
|
||||
|
||||
```bash
|
||||
# Run all mobile tests
|
||||
npx vitest run --config test/mobile/vitest.config.ts
|
||||
npm run test:mobile
|
||||
|
||||
# Run a single test file
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.ts
|
||||
npm run test:mobile -- test/mobile/keyboard.test.ts
|
||||
|
||||
# Quick mode — 6 representative devices, skip full matrix
|
||||
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
||||
# Quick mode: 6 representative devices, skip full matrix
|
||||
CI_QUICK=1 npm run test:mobile
|
||||
|
||||
# Full device matrix only (136 devices)
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
||||
npm run test:mobile -- test/mobile/device-matrix.test.ts
|
||||
|
||||
# Update visual baselines (delete old baselines, re-run)
|
||||
rm -rf test/mobile/snapshots/*.png
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
|
||||
npm run test:mobile -- test/mobile/visual-regression.test.ts
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
@@ -323,6 +323,146 @@ describe('Virtual Keyboard', () => {
|
||||
expect(mainPadding).toBe('');
|
||||
});
|
||||
|
||||
it('coalesces keyboard animation frames into one final terminal fit', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
// `app.terminal` and `app.fitAddon` are only assigned by initTerminal(),
|
||||
// which needs a selected session this harness never creates. Both are
|
||||
// null at rest, and the settle callback returns early on a falsy
|
||||
// terminal — so without stand-ins this test cannot reach the behavior
|
||||
// it asserts. Install the minimum surface the callback touches.
|
||||
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
|
||||
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
|
||||
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
|
||||
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
|
||||
|
||||
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
|
||||
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
|
||||
const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal);
|
||||
let fits = 0;
|
||||
let resizes = 0;
|
||||
let bottomRestores = 0;
|
||||
app.fitAddon.fit = () => {
|
||||
fits++;
|
||||
};
|
||||
KeyboardHandler._sendTerminalResize = () => {
|
||||
resizes++;
|
||||
};
|
||||
app.terminal.scrollToBottom = () => {
|
||||
bottomRestores++;
|
||||
};
|
||||
|
||||
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler._scheduleViewportSettle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler._scheduleViewportSettle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const beforeFinalSettle = { fits, resizes, bottomRestores };
|
||||
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS));
|
||||
const afterFinalSettle = { fits, resizes, bottomRestores };
|
||||
|
||||
app.fitAddon.fit = originalFit;
|
||||
KeyboardHandler._sendTerminalResize = originalSendResize;
|
||||
app.terminal.scrollToBottom = originalScrollToBottom;
|
||||
if (!hadFitAddon) app.fitAddon = null;
|
||||
if (!hadTerminal) app.terminal = null;
|
||||
return { beforeFinalSettle, afterFinalSettle };
|
||||
});
|
||||
|
||||
expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 });
|
||||
expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 1 });
|
||||
});
|
||||
|
||||
// Behavioral counterpart to the test above, driven through the PUBLIC entry
|
||||
// point rather than the internal scheduler. Before this change each
|
||||
// onKeyboardShow armed its own uncoalesced 150ms setTimeout, so a keyboard
|
||||
// animation that reports several viewport steps refit the terminal once per
|
||||
// step — the visible symptom being repeated reflow while the keyboard slides
|
||||
// up. This asserts the observable outcome (one refit for a burst) and so
|
||||
// fails on master by COUNT, not by a missing method.
|
||||
it('refits once for a burst of keyboard viewport steps', async () => {
|
||||
const counts = await page.evaluate(async () => {
|
||||
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
|
||||
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
|
||||
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
|
||||
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
|
||||
|
||||
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
|
||||
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
|
||||
let fits = 0;
|
||||
app.fitAddon.fit = () => {
|
||||
fits++;
|
||||
};
|
||||
KeyboardHandler._sendTerminalResize = () => {};
|
||||
|
||||
// Three viewport steps in quick succession, as a keyboard animation
|
||||
// produces on a real device.
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
|
||||
// Well past both the coalescing window and master's fixed 150ms timer.
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
app.fitAddon.fit = originalFit;
|
||||
KeyboardHandler._sendTerminalResize = originalSendResize;
|
||||
if (!hadFitAddon) app.fitAddon = null;
|
||||
if (!hadTerminal) app.terminal = null;
|
||||
return fits;
|
||||
});
|
||||
|
||||
// Coalesced: one refit for the whole burst. Master fires one per step.
|
||||
expect(counts).toBe(1);
|
||||
});
|
||||
|
||||
// A viewport resize with NO pending show/hide transition must not arm settle
|
||||
// work of its own: keyboard detection can miss a fine-grained OS animation
|
||||
// entirely (sub-150px steps with the baseline chasing the animation), and a
|
||||
// fit against that mid-animation, uncompensated layout resizes the PTY to
|
||||
// transient dims. The resulting SIGWINCH thrash duplicates prompts and
|
||||
// garbles the transcript. Wiggles may only push a pending settle back.
|
||||
it('does not refit on viewport wiggles without a keyboard transition', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const hadTerminal = app.terminal !== null && app.terminal !== undefined;
|
||||
const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined;
|
||||
if (!hadTerminal) app.terminal = { scrollToBottom() {} };
|
||||
if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null };
|
||||
|
||||
const originalFit = app.fitAddon.fit.bind(app.fitAddon);
|
||||
const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler);
|
||||
let fits = 0;
|
||||
app.fitAddon.fit = () => {
|
||||
fits++;
|
||||
};
|
||||
KeyboardHandler._sendTerminalResize = () => {};
|
||||
|
||||
// Wiggle only: nothing pending, so nothing may fire.
|
||||
KeyboardHandler._deferViewportSettle();
|
||||
KeyboardHandler._deferViewportSettle();
|
||||
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
|
||||
const wiggleOnly = fits;
|
||||
|
||||
// A real transition arms the work; a following wiggle defers it but the
|
||||
// settle still fires exactly once.
|
||||
KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler._deferViewportSettle();
|
||||
await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80));
|
||||
const afterTransition = fits;
|
||||
|
||||
app.fitAddon.fit = originalFit;
|
||||
KeyboardHandler._sendTerminalResize = originalSendResize;
|
||||
if (!hadFitAddon) app.fitAddon = null;
|
||||
if (!hadTerminal) app.terminal = null;
|
||||
return { wiggleOnly, afterTransition };
|
||||
});
|
||||
|
||||
expect(result.wiggleOnly).toBe(0);
|
||||
expect(result.afterTransition).toBe(1);
|
||||
});
|
||||
|
||||
it('accessory bar has the simple-mode action buttons', async () => {
|
||||
const actions = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||
@@ -701,7 +841,7 @@ describe('Virtual Keyboard', () => {
|
||||
app.activeSessionId = 'mobile-visible-input-test';
|
||||
app.sessions.set('mobile-visible-input-test', {
|
||||
id: 'mobile-visible-input-test',
|
||||
mode: 'codex',
|
||||
mode: 'claude',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
@@ -747,7 +887,7 @@ describe('Virtual Keyboard', () => {
|
||||
app.activeSessionId = 'mobile-cursor-fallback-test';
|
||||
app.sessions.set('mobile-cursor-fallback-test', {
|
||||
id: 'mobile-cursor-fallback-test',
|
||||
mode: 'codex',
|
||||
mode: 'claude',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
@@ -775,6 +915,58 @@ describe('Virtual Keyboard', () => {
|
||||
expect(state.overlayState?.visible).toBe(true);
|
||||
expect(state.overlayState?.promptPosition).not.toBeNull();
|
||||
});
|
||||
|
||||
it('codex: streams keystrokes write-through and paints predictions (no buffering)', async () => {
|
||||
await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-codex-predict-test';
|
||||
app.sessions.set('mobile-codex-predict-test', {
|
||||
id: 'mobile-codex-predict-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
settings.localEchoEnabled = true;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app._updateLocalEchoState();
|
||||
// Paint a codex-like composer row so the predictWhen gate passes
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) => app.terminal.write('\u203a ', resolve));
|
||||
app.terminal.focus();
|
||||
});
|
||||
|
||||
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||
await page.keyboard.type('hey');
|
||||
|
||||
// Write-through: the keystrokes reach the send path BEFORE any Enter,
|
||||
// and the buffer overlay holds nothing
|
||||
await page.waitForFunction(() => window.__sentInputs?.join('') === 'hey');
|
||||
const typed = await page.evaluate(() => ({
|
||||
policy: app._localEchoPolicy,
|
||||
pendingText: app._localEchoOverlay?.pendingText ?? '',
|
||||
outstanding: app._predictiveEcho?.state.outstanding ?? -1,
|
||||
spans: document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length,
|
||||
}));
|
||||
expect(typed.policy).toBe('predict');
|
||||
expect(typed.pendingText).toBe('');
|
||||
expect(typed.outstanding).toBeGreaterThan(0);
|
||||
expect(typed.spans).toBe(typed.outstanding);
|
||||
|
||||
// No echo ever arrives (stubbed session): TTL self-heals within ~1s
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
|
||||
undefined,
|
||||
{ timeout: 4000 }
|
||||
);
|
||||
const settled = await page.evaluate(() => app._predictiveEcho?.state.outstanding);
|
||||
expect(settled).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||
|
||||
@@ -15,7 +15,7 @@ import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.
|
||||
* Creates a mock context that satisfies all port interfaces.
|
||||
* Pre-populated with one session for convenience.
|
||||
*/
|
||||
export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
export function createMockRouteContext(options?: { sessionId?: string; agentSkillEnabled?: boolean }) {
|
||||
const sessionId = options?.sessionId ?? 'test-session-1';
|
||||
const session = createMockSession(sessionId);
|
||||
const sessions = new Map<string, MockSession>();
|
||||
@@ -86,6 +86,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
getModelConfig: vi.fn(async () => null),
|
||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||
// Default OFF mirrors the shipped setting, so existing tests never touch a
|
||||
// case's .claude/skills. Overridable per test because the create-time
|
||||
// injection call sites are otherwise unreachable from a route test.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||
getLightSessionsState: vi.fn(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user